diff --git a/CHANGELOG.md b/CHANGELOG.md index a95549516..987c0b582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- Added interface language support for English and Simplified Chinese, with a + system-language option in Settings. Scientific database content, activity + names, methods, units, and exported source data remain unchanged. - ([#512](https://github.com/LCA-ActivityBrowser/activity-browser/pull/512)) Added a number of small improvements to the AB, users can now copy existing LCA setups. Additionally, multiple activities can now be duplicated, deleted diff --git a/MANIFEST.in b/MANIFEST.in index 1f3b6c874..bad2df3d6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,3 +8,5 @@ recursive-include activity_browser *.js recursive-include activity_browser *.css recursive-include activity_browser *.txt recursive-include activity_browser *.zip +recursive-include activity_browser *.json +recursive-include activity_browser *.qm diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 412adefe4..bbf6366c0 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -8,7 +8,13 @@ from .application import application from .signals import signals from .settings import ab_settings, project_settings +from .i18n import translation_manager from .info import __version__ as version + +# Install the selected interface language before importing or constructing UI +# classes. The setting itself changes only on restart in the first release. +translation_manager.install(application, ab_settings.language) + from .layouts.main import MainWindow from .plugin import Plugin from .controllers import * diff --git a/activity_browser/actions/activity/activity_delete.py b/activity_browser/actions/activity/activity_delete.py index 836a9cc5b..eea7b5b5e 100644 --- a/activity_browser/actions/activity/activity_delete.py +++ b/activity_browser/actions/activity/activity_delete.py @@ -4,6 +4,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2data.parameters import (ActivityParameter, Group, GroupDependency, @@ -27,20 +28,23 @@ def run(activity_keys: List[tuple]): # retrieve activity objects from the controller using the provided keys activities = [bd.get_activity(key) for key in activity_keys] - warning_text = f"Are you certain you want to delete {len(activities)} activity/activities?" + warning_text = _( + "Are you certain you want to delete {count} activities?", + count=len(activities), + ) # check for downstream processes if any(len(act.upstream()) > 0 for act in activities): # warning text - warning_text += ( - "\n\nOne or more activities have downstream processes. Deleting these activities will remove the " - "exchange from the downstream processes as well." + warning_text += _( + "\n\nOne or more activities have downstream processes. Deleting these " + "activities will also remove their exchanges from downstream processes." ) # alert the user choice = QtWidgets.QMessageBox.warning( application.main_window, - "Deleting activity/activities", + _("Delete activities"), warning_text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No, @@ -49,9 +53,6 @@ def run(activity_keys: List[tuple]): # return if the user cancels if choice == QtWidgets.QMessageBox.No: return - - - # use the activity controller to delete multiple activities for act in activities: db, code = act.key diff --git a/activity_browser/actions/activity/activity_duplicate_to_db.py b/activity_browser/actions/activity/activity_duplicate_to_db.py index b6fef79c0..9ae936334 100644 --- a/activity_browser/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/actions/activity/activity_duplicate_to_db.py @@ -5,6 +5,7 @@ from activity_browser import application, project_settings from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -57,16 +58,19 @@ def request_db(activities): if not target_dbs: QtWidgets.QMessageBox.warning( application.main_window, - "No target database", - "No valid target databases available. Create a new database or set one to writable (not read-only).", + _("No target database"), + _( + "No valid target databases are available. Create a new database " + "or make an existing database writable." + ), ) return # construct a dialog where the user can choose a database to duplicate to target_db, ok = QtWidgets.QInputDialog.getItem( application.main_window, - "Copy activity to database", - "Target database:", + _("Copy activity to database"), + _("Target database:"), target_dbs, 0, False, @@ -82,7 +86,7 @@ def request_db(activities): def confirm_db(to_db: str): user_choice = QtWidgets.QMessageBox.question( application.main_window, - "Duplicate to new database", - f"Copy to {to_db} and open as new tab?", + _("Duplicate to new database"), + _("Copy to {database} and open it in a new tab?", database=to_db), ) return user_choice == user_choice.Yes diff --git a/activity_browser/actions/activity/activity_new.py b/activity_browser/actions/activity/activity_new.py index 527086724..4524079be 100644 --- a/activity_browser/actions/activity/activity_new.py +++ b/activity_browser/actions/activity/activity_new.py @@ -4,6 +4,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod.bw2data import Database from activity_browser.ui.icons import qicons @@ -25,8 +26,8 @@ def run(database_name: str): # ask the user to provide a name for the new activity name, ok = QtWidgets.QInputDialog.getText( application.main_window, - "Create new technosphere activity", - "Please specify an activity name:" + " " * 10, + _("Create new technosphere activity"), + _("Please specify an activity name:") + " " * 10, QtWidgets.QLineEdit.Normal, ) diff --git a/activity_browser/actions/base.py b/activity_browser/actions/base.py index 2572e0f64..7ae55cc4f 100644 --- a/activity_browser/actions/base.py +++ b/activity_browser/actions/base.py @@ -1,6 +1,7 @@ from PySide2 import QtCore, QtGui, QtWidgets from activity_browser import application +from activity_browser.i18n import _ class ABAction: @@ -21,8 +22,9 @@ def triggered(cls, *args, **kwargs): @classmethod def get_QAction(cls, *args, **kwargs) -> QtWidgets.QAction: - action = QtWidgets.QAction(cls.icon, cls.text, None) - action.setToolTip(cls.tooltip) + action = QtWidgets.QAction(cls.icon, _(cls.text) if cls.text else "", None) + tooltip = cls.tooltip or getattr(cls, "tool_tip", None) + action.setToolTip(_(tooltip) if tooltip else "") action.triggered.connect(lambda: cls.triggered(*args, **kwargs)) @@ -33,7 +35,7 @@ def get_QButton(cls, *args, **kwargs): """Convenience function to return a button that has this ABAction as default action.""" button = QtWidgets.QPushButton( cls.icon, - cls.text + _(cls.text) if cls.text else "", ) button.clicked.connect(lambda x: cls.triggered(*args, **kwargs)) return button @@ -46,8 +48,14 @@ def wrapper(*args, **kwargs): except Exception as e: QtWidgets.QMessageBox.critical( application.main_window, - f"An error occurred: {type(e).__name__}", - f"An error occurred, check the logs for more information \n\n {str(e)}", + _( + "An error occurred: {error_type}", + error_type=type(e).__name__, + ), + _( + "An error occurred, check the logs for more information\n\n{error}", + error=str(e), + ), QtWidgets.QMessageBox.Ok, ) raise e diff --git a/activity_browser/actions/biosphere_update.py b/activity_browser/actions/biosphere_update.py index def55d202..0953c2efe 100644 --- a/activity_browser/actions/biosphere_update.py +++ b/activity_browser/actions/biosphere_update.py @@ -3,6 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.info import __ei_versions__ +from activity_browser.i18n import _ from activity_browser.ui.widgets import (BiosphereUpdater, EcoinventVersionDialog) from activity_browser.utils import sort_semantic_versions @@ -25,10 +26,12 @@ def run(): # warn user of consequences of updating warn_dialog = QtWidgets.QMessageBox.question( application.main_window, - "Update biosphere3?", - "Newer versions of the biosphere database may not\n" - "always be compatible with older ecoinvent versions.\n" - "\nUpdating the biosphere3 database cannot be undone!\n", + _("Update biosphere3?"), + _( + "Newer versions of the biosphere database may not always be " + "compatible with older ecoinvent versions.\n\nUpdating the " + "biosphere3 database cannot be undone!" + ), QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Abort, QtWidgets.QMessageBox.Abort, ) diff --git a/activity_browser/actions/calculation_setup/cs_delete.py b/activity_browser/actions/calculation_setup/cs_delete.py index a22f32c45..55a6d3148 100644 --- a/activity_browser/actions/calculation_setup/cs_delete.py +++ b/activity_browser/actions/calculation_setup/cs_delete.py @@ -4,6 +4,7 @@ from activity_browser import application, signals from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -25,8 +26,8 @@ def run(cs_name: str): # ask the user whether they are sure to delete the calculation setup warning = QtWidgets.QMessageBox.warning( application.main_window, - f"Deleting Calculation Setup: {cs_name}", - "Are you sure you want to delete this calculation setup?", + _("Delete calculation setup: {name}", name=cs_name), + _("Are you sure you want to delete this calculation setup?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No, ) @@ -41,7 +42,7 @@ def run(cs_name: str): QtWidgets.QMessageBox.information( application.main_window, - f"Deleting Calculation Setup: {cs_name}", - "Calculation setup was succesfully deleted.", + _("Delete calculation setup: {name}", name=cs_name), + _("The calculation setup was successfully deleted."), QtWidgets.QMessageBox.Ok, ) diff --git a/activity_browser/actions/calculation_setup/cs_duplicate.py b/activity_browser/actions/calculation_setup/cs_duplicate.py index 597edca85..b9e4ed584 100644 --- a/activity_browser/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/actions/calculation_setup/cs_duplicate.py @@ -4,6 +4,7 @@ from activity_browser import application, signals from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -26,8 +27,8 @@ def run(cs_name: str): # prompt the user to give a name for the new calculation setup new_name, ok = QtWidgets.QInputDialog.getText( application.main_window, - f"Duplicate '{cs_name}'", - "Name of the duplicated calculation setup:" + " " * 10, + _("Duplicate '{name}'", name=cs_name), + _("Name of the duplicated calculation setup:") + " " * 10, ) # return if the user cancels or gives no name @@ -38,8 +39,8 @@ def run(cs_name: str): if new_name in bd.calculation_setups: QtWidgets.QMessageBox.warning( application.main_window, - "Not possible", - "A calculation setup with this name already exists.", + _("Not possible"), + _("A calculation setup with this name already exists."), ) return diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/actions/calculation_setup/cs_new.py index 105ef1ccf..b4b293ade 100644 --- a/activity_browser/actions/calculation_setup/cs_new.py +++ b/activity_browser/actions/calculation_setup/cs_new.py @@ -4,6 +4,7 @@ from activity_browser import application, signals from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -26,8 +27,8 @@ def run(): # prompt the user to give a name for the new calculation setup name, ok = QtWidgets.QInputDialog.getText( application.main_window, - "Create new calculation setup", - "Name of new calculation setup:" + " " * 10, + _("Create new calculation setup"), + _("Name of new calculation setup:") + " " * 10, ) # return if the user cancels or gives no name @@ -38,8 +39,8 @@ def run(): if name in bd.calculation_setups: QtWidgets.QMessageBox.warning( application.main_window, - "Not possible", - "A calculation setup with this name already exists.", + _("Not possible"), + _("A calculation setup with this name already exists."), ) return diff --git a/activity_browser/actions/calculation_setup/cs_rename.py b/activity_browser/actions/calculation_setup/cs_rename.py index 735548f1f..3caac41ce 100644 --- a/activity_browser/actions/calculation_setup/cs_rename.py +++ b/activity_browser/actions/calculation_setup/cs_rename.py @@ -4,6 +4,7 @@ from activity_browser import application, signals from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -26,8 +27,8 @@ def run(cs_name: str): # prompt the user to give a name for the new calculation setup new_name, ok = QtWidgets.QInputDialog.getText( application.main_window, - f"Rename '{cs_name}'", - "New name of this calculation setup:" + " " * 10, + _("Rename '{name}'", name=cs_name), + _("New name of this calculation setup:") + " " * 10, ) # return if the user cancels or gives no name @@ -38,8 +39,8 @@ def run(cs_name: str): if new_name in bd.calculation_setups: QtWidgets.QMessageBox.warning( application.main_window, - "Not possible", - "A calculation setup with this name already exists.", + _("Not possible"), + _("A calculation setup with this name already exists."), ) return diff --git a/activity_browser/actions/database/database_delete.py b/activity_browser/actions/database/database_delete.py index 26994a52a..b6078ce3a 100644 --- a/activity_browser/actions/database/database_delete.py +++ b/activity_browser/actions/database/database_delete.py @@ -3,6 +3,7 @@ from activity_browser import application, project_settings from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2data.backends.proxies import (ExchangeDataset, Exchanges) @@ -39,16 +40,38 @@ def run(db_name: str): n_upstream_excs = len(excs) # construct warning text - text = f"Are you sure you want to delete database '{db_name}'?" - if n_records: - text += f" It contains {n_records} activities" - if n_upstream_excs: - text += f" and {n_upstream_excs} exchanges to other databases" + if n_records and n_upstream_excs: + text = _( + "Are you sure you want to delete database '{database}'? It contains " + "{activities} activities and {exchanges} exchanges to other databases.", + database=db_name, + activities=n_records, + exchanges=n_upstream_excs, + ) + elif n_records: + text = _( + "Are you sure you want to delete database '{database}'? It contains " + "{activities} activities.", + database=db_name, + activities=n_records, + ) + elif n_upstream_excs: + text = _( + "Are you sure you want to delete database '{database}'? It has " + "{exchanges} exchanges to other databases.", + database=db_name, + exchanges=n_upstream_excs, + ) + else: + text = _( + "Are you sure you want to delete database '{database}'?", + database=db_name, + ) # ask the user for confirmation QtWidgets.QApplication.restoreOverrideCursor() response = QtWidgets.QMessageBox.question( - application.main_window, "Delete database?", text + application.main_window, _("Delete database?"), text ) # return if the user cancels diff --git a/activity_browser/actions/database/database_duplicate.py b/activity_browser/actions/database/database_duplicate.py index 2aec75cea..7506bee23 100644 --- a/activity_browser/actions/database/database_duplicate.py +++ b/activity_browser/actions/database/database_duplicate.py @@ -2,6 +2,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons from activity_browser.ui.threading import ABThread @@ -26,8 +27,8 @@ def run(db_name: str): new_name, ok = QtWidgets.QInputDialog.getText( application.main_window, - f"Copy {db_name}", - "Name of new database:" + " " * 25, + _("Copy {database}", database=db_name), + _("Name of new database:") + " " * 25, ) if not new_name or not ok: return @@ -35,8 +36,8 @@ def run(db_name: str): if new_name in bd.databases: QtWidgets.QMessageBox.information( application.main_window, - "Not possible", - "A database with this name already exists.", + _("Not possible"), + _("A database with this name already exists."), ) return @@ -46,9 +47,14 @@ def run(db_name: str): class DuplicateDatabaseDialog(QtWidgets.QProgressDialog): def __init__(self, from_db: str, to_db: str, parent=None): super().__init__(parent=parent) - self.setWindowTitle("Duplicating database") + self.setWindowTitle(_("Duplicating database")) self.setLabelText( - f"Duplicating existing database {from_db} to new database {to_db}:" + _( + "Duplicating existing database {source} to new database " + "{target}:", + source=from_db, + target=to_db, + ) ) self.setModal(True) self.setRange(0, 0) diff --git a/activity_browser/actions/database/database_new.py b/activity_browser/actions/database/database_new.py index 6f303b321..7cf9ba3df 100644 --- a/activity_browser/actions/database/database_new.py +++ b/activity_browser/actions/database/database_new.py @@ -2,6 +2,7 @@ from activity_browser import application, project_settings, signals from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -22,8 +23,8 @@ class DatabaseNew(ABAction): def run(): name, ok = QtWidgets.QInputDialog.getText( application.main_window, - "Create new database", - "Name of new database:" + " " * 25, + _("Create new database"), + _("Name of new database:") + " " * 25, ) if not ok or not name: @@ -32,8 +33,8 @@ def run(): if name in bd.databases: QtWidgets.QMessageBox.information( application.main_window, - "Not possible", - "A database with this name already exists.", + _("Not possible"), + _("A database with this name already exists."), ) return diff --git a/activity_browser/actions/method/cf_new.py b/activity_browser/actions/method/cf_new.py index 4de62c16d..404f30b65 100644 --- a/activity_browser/actions/method/cf_new.py +++ b/activity_browser/actions/method/cf_new.py @@ -4,6 +4,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -29,9 +30,11 @@ def run(method_name: tuple, keys: List[tuple]): if len(unique_keys) < len(keys): QtWidgets.QMessageBox.warning( application.main_window, - "Duplicate characterization factors", - "One or more of these elementary flows already exist within this method. Duplicate flows will not be " - "added", + _("Duplicate characterization factors"), + _( + "One or more of these elementary flows already exist in this " + "method. Duplicate flows will not be added." + ), ) # return if there are no new keys diff --git a/activity_browser/actions/method/cf_remove.py b/activity_browser/actions/method/cf_remove.py index 51d0c5b55..3e3667acc 100644 --- a/activity_browser/actions/method/cf_remove.py +++ b/activity_browser/actions/method/cf_remove.py @@ -4,6 +4,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -23,8 +24,11 @@ def run(method_name: tuple, char_factors: List[tuple]): # ask the user whether they are sure to delete the calculation setup warning = QtWidgets.QMessageBox.warning( application.main_window, - "Deleting Characterization Factors", - f"Are you sure you want to delete {len(char_factors)} CF('s)?", + _("Delete characterization factors"), + _( + "Are you sure you want to delete {count} characterization factors?", + count=len(char_factors), + ), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No, ) diff --git a/activity_browser/actions/method/method_delete.py b/activity_browser/actions/method/method_delete.py index 2471a86d2..2082a5a88 100644 --- a/activity_browser/actions/method/method_delete.py +++ b/activity_browser/actions/method/method_delete.py @@ -5,6 +5,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -28,14 +29,20 @@ def run(methods: List[tuple]): all_methods = [bd.Method(method) for method in methods] if len(all_methods) == 1: - warning_text = f"Are you sure you want to delete this method?\n\n{methods[0]}" + warning_text = _( + "Are you sure you want to delete this method?\n\n{method}", + method=methods[0], + ) else: - warning_text = f"Are you sure you want to delete {len(all_methods)} methods?" + warning_text = _( + "Are you sure you want to delete {count} methods?", + count=len(all_methods), + ) # warn the user about the pending deletion warning = QtWidgets.QMessageBox.warning( application.main_window, - "Deleting Method", + _("Delete method"), warning_text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No, diff --git a/activity_browser/actions/method/method_duplicate.py b/activity_browser/actions/method/method_duplicate.py index dc3717fcc..bcc877db7 100644 --- a/activity_browser/actions/method/method_duplicate.py +++ b/activity_browser/actions/method/method_duplicate.py @@ -3,6 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons from activity_browser.ui.widgets import TupleNameDialog @@ -37,8 +38,8 @@ def run(methods: List[tuple], level: str): # retrieve the new name(s) from the user and return if canceled dialog = TupleNameDialog.get_combined_name( application.main_window, - "Impact category name", - "Combined name:", + _("Impact category name"), + _("Combined name:"), selected_method, " - Copy", ) diff --git a/activity_browser/actions/migrations_install.py b/activity_browser/actions/migrations_install.py index 14d9305c3..4f87df22a 100644 --- a/activity_browser/actions/migrations_install.py +++ b/activity_browser/actions/migrations_install.py @@ -2,6 +2,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.ui import icons, threading from activity_browser.mod.bw2io.migrations import ab_create_core_migrations @@ -23,7 +24,7 @@ def update_dialog_slot(progress: int, label: str): dialog = QtWidgets.QProgressDialog(application.main_window) - dialog.setWindowTitle("Installing migrations") + dialog.setWindowTitle(_("Installing migrations")) dialog.setMaximum(100) dialog.setCancelButton(None) diff --git a/activity_browser/actions/parameter/parameter_new.py b/activity_browser/actions/parameter/parameter_new.py index 2eea4ad79..a6955e305 100644 --- a/activity_browser/actions/parameter/parameter_new.py +++ b/activity_browser/actions/parameter/parameter_new.py @@ -5,6 +5,7 @@ from activity_browser import actions, application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks as bc +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2data.parameters import ActivityParameter from activity_browser.ui.icons import qicons @@ -50,7 +51,10 @@ def run(activity_key: Tuple[str, str]): if name[0] in ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "#"): error = QtWidgets.QErrorMessage() error.showMessage( - "
Parameter names must not start with a digit, hyphen, or hash character
" + _( + "Parameter names must not start with a digit, hyphen, " + "or hash character.
" + ) ) error.exec_() return @@ -107,12 +111,12 @@ def _get_group(self): class SelectParameterTypePage(QtWidgets.QWizardPage): def __init__(self, parent): super().__init__(parent) - self.setTitle("Select the type of parameter to create.") + self.setTitle(_("Select the type of parameter to create.")) self.key = parent.key layout = QtWidgets.QVBoxLayout() - box = QtWidgets.QGroupBox("Types:") + box = QtWidgets.QGroupBox(_("Types:")) # Explicitly set the stylesheet to avoid parent classes overriding box.setStyleSheet( "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" @@ -122,7 +126,7 @@ def __init__(self, parent): self.button_group = QtWidgets.QButtonGroup() self.button_group.setExclusive(True) for i, s in enumerate(PARAMETER_STRINGS): - button = QtWidgets.QRadioButton(s) + button = QtWidgets.QRadioButton(_(s)) self.button_group.addButton(button, i) box_layout.addWidget(button) # If we have a complete key, pre-select the activity parameter btn. @@ -148,12 +152,12 @@ def selected(self) -> int: class CompleteParameterPage(QtWidgets.QWizardPage): def __init__(self, parent): super().__init__(parent) - self.setTitle("Fill out required values for the parameter") + self.setTitle(_("Fill out required values for the parameter")) self.parent = parent layout = QtWidgets.QVBoxLayout() self.setLayout(layout) - box = QtWidgets.QGroupBox("Data:") + box = QtWidgets.QGroupBox(_("Data:")) box.setStyleSheet( "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" @@ -164,11 +168,11 @@ def __init__(self, parent): self.key = parent.key - self.name_label = QtWidgets.QLabel("Name:") + self.name_label = QtWidgets.QLabel(_("Name:")) self.name = QtWidgets.QLineEdit() grid.addWidget(self.name_label, 0, 0) grid.addWidget(self.name, 0, 1) - self.amount_label = QtWidgets.QLabel("Amount:") + self.amount_label = QtWidgets.QLabel(_("Amount:")) self.amount = QtWidgets.QLineEdit() locale = QtCore.QLocale(QtCore.QLocale.English) locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) @@ -177,7 +181,7 @@ def __init__(self, parent): self.amount.setValidator(validator) grid.addWidget(self.amount_label, 1, 0) grid.addWidget(self.amount, 1, 1) - self.database_label = QtWidgets.QLabel("Database:") + self.database_label = QtWidgets.QLabel(_("Database:")) self.database = QtWidgets.QComboBox() grid.addWidget(self.database_label, 2, 0) grid.addWidget(self.database, 2, 1) diff --git a/activity_browser/actions/parameter/parameter_new_automatic.py b/activity_browser/actions/parameter/parameter_new_automatic.py index dc0f90aca..82b729662 100644 --- a/activity_browser/actions/parameter/parameter_new_automatic.py +++ b/activity_browser/actions/parameter/parameter_new_automatic.py @@ -5,6 +5,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2data.parameters import ActivityParameter from activity_browser.ui.icons import qicons @@ -27,10 +28,15 @@ def run(activity_keys: List[Tuple]): for key in activity_keys: act = bd.get_activity(key) if act.get("type", "process") != "process": - issue = f"Activity must be 'process' type, '{act.get('name')}' is type '{act.get('type')}'." + issue = _( + "Activity must be of type 'process'; '{activity}' is of type " + "'{activity_type}'.", + activity=act.get("name"), + activity_type=act.get("type"), + ) QtWidgets.QMessageBox.warning( application.main_window, - "Not allowed", + _("Not allowed"), issue, QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/actions/parameter/parameter_rename.py b/activity_browser/actions/parameter/parameter_rename.py index c1eee3147..218bee5c5 100644 --- a/activity_browser/actions/parameter/parameter_rename.py +++ b/activity_browser/actions/parameter/parameter_rename.py @@ -4,6 +4,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod.bw2data.parameters import (ActivityParameter, DatabaseParameter, ProjectParameter, @@ -26,8 +27,8 @@ class ParameterRename(ABAction): def run(parameter: Any): new_name, ok = QtWidgets.QInputDialog.getText( application.main_window, - "Rename parameter", - f"Rename parameter '{parameter.name}' to:", + _("Rename parameter"), + _("Rename parameter '{name}' to:", name=parameter.name), ) if not ok or not new_name: @@ -49,7 +50,7 @@ def run(parameter: Any): except Exception as e: QtWidgets.QMessageBox.warning( application.main_window, - "Could not save changes", + _("Could not save changes"), str(e), QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/actions/project/project_delete.py b/activity_browser/actions/project/project_delete.py index 87a96c65d..591e983f5 100644 --- a/activity_browser/actions/project/project_delete.py +++ b/activity_browser/actions/project/project_delete.py @@ -2,6 +2,7 @@ from activity_browser import ab_settings, application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons from activity_browser.ui.widgets import ProjectDeletionDialog @@ -26,8 +27,11 @@ def run(): if project_to_delete == ab_settings.startup_project: QtWidgets.QMessageBox.information( application.main_window, - "Not possible", - "Can't delete the startup project. Please select another startup project in the settings first.", + _("Not possible"), + _( + "The startup project cannot be deleted. Select a different " + "startup project in Settings first." + ), ) return @@ -46,5 +50,7 @@ def run(): # inform the user of successful deletion QtWidgets.QMessageBox.information( - application.main_window, "Project deleted", "Project successfully deleted" + application.main_window, + _("Project deleted"), + _("The project was successfully deleted."), ) diff --git a/activity_browser/actions/project/project_duplicate.py b/activity_browser/actions/project/project_duplicate.py index 77d3bbcd4..f5141675e 100644 --- a/activity_browser/actions/project/project_duplicate.py +++ b/activity_browser/actions/project/project_duplicate.py @@ -2,6 +2,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -22,8 +23,11 @@ class ProjectDuplicate(ABAction): def run(): name, ok = QtWidgets.QInputDialog.getText( application.main_window, - "Duplicate current project", - f"Duplicate current project ({bd.projects.current}) to new name:" + _("Duplicate current project"), + _( + "Duplicate current project ({project}) with the new name:", + project=bd.projects.current, + ) + " " * 10, ) @@ -33,8 +37,8 @@ def run(): if name in bd.projects: QtWidgets.QMessageBox.information( application.main_window, - "Not possible.", - "A project with this name already exists.", + _("Not possible."), + _("A project with this name already exists."), ) return diff --git a/activity_browser/actions/project/project_export.py b/activity_browser/actions/project/project_export.py index 2c3f15282..10a40c445 100644 --- a/activity_browser/actions/project/project_export.py +++ b/activity_browser/actions/project/project_export.py @@ -8,6 +8,7 @@ from activity_browser import application from activity_browser.mod import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.ui.threading import ABThread log = getLogger(__name__) @@ -29,9 +30,9 @@ def run(): # get target path from the user save_path, save_type = QtWidgets.QFileDialog.getSaveFileName( parent=application.main_window, - caption="Choose where", + caption=_("Choose where to export the project"), dir=os.path.expanduser(f"~/{bd.projects.current}.tar.gz"), - filter="Tar-file (*.tar.gz)" + filter=_("Tar archive (*.tar.gz)") ) if not save_path: return @@ -39,11 +40,11 @@ def run(): # setup dialog progress = QtWidgets.QProgressDialog( parent=application.main_window, - labelText="Exporting project", + labelText=_("Exporting project"), maximum=0 ) progress.setCancelButton(None) - progress.setWindowTitle("Exporting project") + progress.setWindowTitle(_("Exporting project")) progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) diff --git a/activity_browser/actions/project/project_import.py b/activity_browser/actions/project/project_import.py index 777cc484a..42f03d7d8 100644 --- a/activity_browser/actions/project/project_import.py +++ b/activity_browser/actions/project/project_import.py @@ -9,6 +9,7 @@ from activity_browser import application from activity_browser.mod import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.ui.icons import qicons from activity_browser.ui.threading import ABThread @@ -32,11 +33,11 @@ def run(cls): """Import a project into AB based on file chosen by user.""" # get the path from the user - path, _ = QtWidgets.QFileDialog.getOpenFileName( + path = QtWidgets.QFileDialog.getOpenFileName( parent=application.main_window, - caption='Choose project file to import', - filter='Tar archive (*.tar.gz);; All files (*.*)' - ) + caption=_("Choose project file to import"), + filter=_("Tar archive (*.tar.gz);;All files (*.*)"), + )[0] if not path: return # create a name suggestion based on the file name @@ -44,12 +45,12 @@ def run(cls): # get a new project name from the user: while True: - project_name, _ = QtWidgets.QInputDialog.getText( + project_name = QtWidgets.QInputDialog.getText( application.main_window, - 'Choose project name', - 'Choose a name for your project', + _("Choose project name"), + _("Choose a name for your project"), text=suggestion - ) + )[0] if not project_name: return @@ -57,19 +58,19 @@ def run(cls): # this name already exists, inform user and ask again. QtWidgets.QMessageBox.information( application.main_window, - "Not possible.", - "A project with this name already exists." + _("Not possible."), + _("A project with this name already exists."), ) else: break # setup dialog progress = QtWidgets.QProgressDialog( parent=application.main_window, - labelText="Importing project", + labelText=_("Importing project"), maximum=0 ) progress.setCancelButton(None) - progress.setWindowTitle("Importing project") + progress.setWindowTitle(_("Importing project")) progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) @@ -95,7 +96,7 @@ def get_project_name(fp): for member in tar: if member.name[-17:] == "project-name.json": return json.load(reader(tar.extractfile(member)))["name"] - raise ValueError("Couldn't find project name file in archive") + raise ValueError(_("Could not find the project name file in the archive.")) class ImportThread(ABThread): @@ -106,4 +107,3 @@ def run_safely(self): f'\nNAME: {self.project_name}') backup.restore_project_directory(fp=self.path, project_name=self.project_name) log.info(f"Project `{self.project_name}` imported.") - diff --git a/activity_browser/actions/project/project_new.py b/activity_browser/actions/project/project_new.py index 7b6f7c5bd..a2fbb9c0c 100644 --- a/activity_browser/actions/project/project_new.py +++ b/activity_browser/actions/project/project_new.py @@ -2,6 +2,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -22,8 +23,8 @@ class ProjectNew(ABAction): def run(): name, ok = QtWidgets.QInputDialog.getText( application.main_window, - "Create new project", - "Name of new project:" + " " * 25, + _("Create new project"), + _("Name of new project:") + " " * 25, ) if not ok or not name: @@ -32,8 +33,8 @@ def run(): if name in bd.projects: QtWidgets.QMessageBox.information( application.main_window, - "Not possible.", - "A project with this name already exists.", + _("Not possible."), + _("A project with this name already exists."), ) return diff --git a/activity_browser/bwutils/calculations.py b/activity_browser/bwutils/calculations.py index 479480a7f..6d54cdd4b 100644 --- a/activity_browser/bwutils/calculations.py +++ b/activity_browser/bwutils/calculations.py @@ -4,6 +4,8 @@ from bw2calc.errors import BW2CalcError from PySide2.QtWidgets import QApplication +from activity_browser.i18n import _ + from ..bwutils import (MLCA, Contributions, MonteCarloLCA, SuperstructureContributions, SuperstructureMLCA) from .errors import CriticalCalculationError, ScenarioExchangeNotFoundError @@ -21,7 +23,9 @@ def do_LCA_calculations(data: dict): mlca = MLCA(cs_name) contributions = Contributions(mlca) except KeyError as e: - raise BW2CalcError("LCA Failed", str(e)).with_traceback(e.__traceback__) + raise BW2CalcError(_("LCA calculation failed"), str(e)).with_traceback( + e.__traceback__ + ) elif calculation_type == "scenario": try: df = data.get("data") @@ -30,7 +34,7 @@ def do_LCA_calculations(data: dict): except AssertionError as e: # This occurs if the superstructure itself detects something is wrong. QApplication.restoreOverrideCursor() - raise BW2CalcError("Scenario LCA failed.", str(e)).with_traceback( + raise BW2CalcError(_("Scenario LCA calculation failed"), str(e)).with_traceback( e.__traceback__ ) except ValueError as e: @@ -38,12 +42,17 @@ def do_LCA_calculations(data: dict): # exchanges mentioned in the superstructure data. QApplication.restoreOverrideCursor() raise BW2CalcError( - "Scenario LCA failed.", - "Constructed LCA matrix does not contain any exchanges from the superstructure", + _("Scenario LCA calculation failed"), + _( + "The constructed LCA matrix contains none of the exchanges " + "from the scenario data." + ), ).with_traceback(e.__traceback__) except KeyError as e: QApplication.restoreOverrideCursor() - raise BW2CalcError("LCA Failed", str(e)).with_traceback(e.__traceback__) + raise BW2CalcError(_("LCA calculation failed"), str(e)).with_traceback( + e.__traceback__ + ) except CriticalCalculationError as e: QApplication.restoreOverrideCursor() raise Exception(e) diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index 6b0bc9f2d..c84a11a60 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -8,6 +8,8 @@ import pandas as pd from PySide2.QtWidgets import QApplication, QMessageBox +from activity_browser.i18n import _ + from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2analyzer import ABContributionAnalysis @@ -120,10 +122,10 @@ def __init__(self, cs_name: str): # all values of rf are the individual reference flow items. if [v for rf in cs["inv"] for v in rf.values() if v == 0]: msg = QMessageBox() - msg.setWindowTitle("Reference flows equal 0") - msg.setText("All reference flows must be non-zero.") + msg.setWindowTitle(_("Reference flows equal zero")) + msg.setText(_("All reference flows must be non-zero.")) msg.setInformativeText( - "Please enter a valid value before calculating LCA results again." + _("Please enter a valid value before calculating LCA results again.") ) msg.setIcon(QMessageBox.Warning) QApplication.restoreOverrideCursor() diff --git a/activity_browser/bwutils/superstructure/dataframe.py b/activity_browser/bwutils/superstructure/dataframe.py index e165f1208..4560979ae 100644 --- a/activity_browser/bwutils/superstructure/dataframe.py +++ b/activity_browser/bwutils/superstructure/dataframe.py @@ -8,6 +8,8 @@ from PySide2.QtCore import Qt from PySide2.QtWidgets import QApplication, QPushButton +from activity_browser.i18n import _ + from ..errors import ScenarioDatabaseNotFoundError from ..metadata import AB_metadata from ..utils import Index @@ -216,41 +218,39 @@ def exchange_replace_database( # prepare a warning message in case unlinkable activities were found in the scenario dataframe QApplication.restoreOverrideCursor() if len(critical["from database"]) > 1: - msg = ( - f'Multiple activities could not be "relinked" to the local database.While importing the scenario difference files one, or more, of the scenarios could not be found " "between the files.
In these circumstances the Activity-Browser will only retain those " "scenarios found in common between these files. If some desired scenarios are not included, then " "please inspect your scenario files for the relevant columns." ) warning = ABPopup.abWarning( - "Scenarios being dropped", msg, QPushButton("Ok"), QPushButton("Cancel") + _("Scenarios being dropped"), + msg, + QPushButton(_("OK")), + QPushButton(_("Cancel")), ) warning.dataframe(pd.DataFrame({"Scenarios": list(absent)}), ["Scenarios"]) response = warning.exec_() @@ -394,14 +402,17 @@ def exchangesPopup() -> ABPopup: ------- A QDialog with a critical Error """ - msg = ( + msg = _( "One, or several, exchanges (rows) in the scenario file could not be found in the database (meaning:" " a part or all of the exchange information, i.e. input or output product/activity/unit/geography, or the" " key, have no match in the project databases).
It is not possible to proceed at this point." " you may save the scenario file with an additional column indicating the problematic exchanges.
" ) pop = ABPopup.abCritical( - "Exchange(s) not found", msg, QPushButton("Save"), QPushButton("Cancel") + _("Exchanges not found"), + msg, + QPushButton(_("Save")), + QPushButton(_("Cancel")), ) pop.save_options() return pop @@ -497,14 +508,19 @@ def check_scenario_exchange_values(df: pd.DataFrame, cols: pd.Index): nas = _df.loc[:, cols].isna() if nas.all(axis=0).all(): msg = ( - "No exchange values could be observed in the last loaded scenario file. " - + "Exchange values must be recorded in a labelled scenario column with a name distinguishable from the" - + " default (required) columns, which are:
" + _( + "No exchange values were found in the last loaded scenario " + "file. Exchange values must be recorded in a named scenario " + "column distinct from the required default columns shown below:
" + ) + SuperstructureManager.edit_superstructure_for_string() - + "Please check the file contents for the scenario columns and the exchange amounts before loading again.
" + + _( + "Check the scenario columns and exchange values in the file " + "before loading it again.
" + ) ) critical = ABPopup.abCritical( - "No scenario exchange data", msg, QPushButton("Cancel") + _("No scenario exchange data"), msg, QPushButton(_("Cancel")) ) critical.exec_() raise ScenarioExchangeDataNotFoundError @@ -520,16 +536,16 @@ def check_scenario_exchange_values(df: pd.DataFrame, cols: pd.Index): bad_entries = pd.DataFrame(index=_df.index) for col in cols: bad_entries[col] = pd.to_numeric(df.loc[:, col], errors="coerce") - msg = ( + msg = _( "Non-numeric data is present in the scenario exchange columns.
The Activity-Browser can " "only deal with numeric data for the calculations. To resolve this corrections will need to be made " "to these values in the scenario file.
" ) critical = ABPopup.abCritical( - "Bad (non-numeric) input data", + _("Non-numeric input data"), msg, - QPushButton("Save"), - QPushButton("Cancel"), + QPushButton(_("Save")), + QPushButton(_("Cancel")), ) QApplication.restoreOverrideCursor() critical.dataframe(df[bad_entries.isna().any(axis=1)], SUPERSTRUCTURE) @@ -580,16 +596,16 @@ def check_duplicates( duplicated[count] = duplicates if duplicated: - msg = ( + msg = _( "Duplicates have been found, meaning that there are several rows in the scenario file describing " "scenarios for the same flow. The AB can deal with this by discarding all but the last row for this " "exchange.
Press 'Ok' to proceed, press 'Cancel' to abort.
" ) warning = ABPopup.abWarning( - "Duplicate flow exchanges", + _("Duplicate flow exchanges"), msg, - QPushButton("Ok"), - QPushButton("Cancel"), + QPushButton(_("OK")), + QPushButton(_("Cancel")), ) warning.dataframe( pd.concat([file for file in duplicated.values()]), @@ -635,16 +651,16 @@ def _check_duplicate( df.index = pd.Index([str(i) for i in range(df.shape[0])]) duplicates = df.duplicated(index, keep=False) if duplicates.any(): - msg = ( + msg = _( "Duplicates have been found, meaning that there are several rows in the scenario file describing " "scenarios for the same flow. The AB can deal with this by discarding all but the last row for this " "exchange.
Press 'Ok' to proceed, press 'Cancel' to abort.
" ) warning = ABPopup.abWarning( - "Duplicate flow exchanges", + _("Duplicate flow exchanges"), msg, - QPushButton("Ok"), - QPushButton("Cancel"), + QPushButton(_("OK")), + QPushButton(_("Cancel")), ) warning.dataframe(df.loc[duplicates], SUPERSTRUCTURE) QApplication.restoreOverrideCursor() diff --git a/activity_browser/bwutils/superstructure/mlca.py b/activity_browser/bwutils/superstructure/mlca.py index 31d6c4117..357689fad 100644 --- a/activity_browser/bwutils/superstructure/mlca.py +++ b/activity_browser/bwutils/superstructure/mlca.py @@ -5,6 +5,8 @@ import pandas as pd from PySide2.QtWidgets import QPushButton +from activity_browser.i18n import _ + from activity_browser.mod import bw2data as bd from ..commontasks import format_activity_label @@ -151,9 +153,19 @@ def convert(idx: Index) -> tuple: except (ValueError, KeyError) as e: # This is to be used as a fail safe for the case where we don't catch a bad exchange during the import # process, or if something else causes an issue with the exchange - msg = f"One of the activities in the exchange between ({index.input.database}, {index.input.code}) and ({index.output.database}, {index.output.code}) from the scenario file is not present within the designated database. Please check both keys for this exchange within your scenario file with the corresponding databases." + msg = _( + "One of the activities in the scenario exchange between " + "({input_database}, {input_code}) and ({output_database}, " + "{output_code}) is not present in the designated database. " + "Check both exchange keys and their corresponding databases in " + "the scenario file.", + input_database=index.input.database, + input_code=index.input.code, + output_database=index.output.database, + output_code=index.output.code, + ) critical = ABPopup.abCritical( - "Scenario Key Error", msg, QPushButton("Cancel") + _("Scenario key error"), msg, QPushButton(_("Cancel")) ) critical.exec_() raise ScenarioExchangeNotFoundError diff --git a/activity_browser/docs/wiki/Settings.md b/activity_browser/docs/wiki/Settings.md index 6308e1128..0be83dfa9 100644 --- a/activity_browser/docs/wiki/Settings.md +++ b/activity_browser/docs/wiki/Settings.md @@ -1,5 +1,18 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). +# Settings + +Open **Project > Settings...** to configure Activity Browser startup options. + +## Interface language + +Use the **Language** menu to choose one of the following options: + +- **System default** uses Simplified Chinese on a Chinese-language system and + English on other systems. +- **English** always uses the English interface. +- **Simplified Chinese** always uses the Simplified Chinese interface. + +Save the settings and restart Activity Browser to apply a language change. + +The language setting applies only to the Activity Browser interface. Scientific +database content, activity names, impact assessment methods, units, and exported +source data are not translated or otherwise modified. diff --git a/activity_browser/i18n.py b/activity_browser/i18n.py new file mode 100644 index 000000000..119a98331 --- /dev/null +++ b/activity_browser/i18n.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +"""Internationalization support for Activity Browser's user interface. + +The scientific data handled by Activity Browser deliberately does not pass +through this module. Catalog keys are the English user-interface strings and +catalog values are their translations. +""" + +import json +from importlib import resources +from logging import getLogger +from pathlib import Path +from typing import Dict, Optional, Tuple, Union + +from PySide2.QtCore import QLibraryInfo, QLocale, QTranslator + +log = getLogger(__name__) + +SYSTEM_LANGUAGE = "system" +ENGLISH = "en_US" +SIMPLIFIED_CHINESE = "zh_CN" +SUPPORTED_LANGUAGES = (SYSTEM_LANGUAGE, ENGLISH, SIMPLIFIED_CHINESE) + +_LANGUAGE_LABELS = { + SYSTEM_LANGUAGE: "System default", + ENGLISH: "English", + SIMPLIFIED_CHINESE: "Simplified Chinese", +} + +_LANGUAGE_ALIASES = { + "": SYSTEM_LANGUAGE, + "auto": SYSTEM_LANGUAGE, + "default": SYSTEM_LANGUAGE, + "system": SYSTEM_LANGUAGE, + "system_default": SYSTEM_LANGUAGE, + "跟随系统": SYSTEM_LANGUAGE, + "系统默认": SYSTEM_LANGUAGE, + "en": ENGLISH, + "en_us": ENGLISH, + "english": ENGLISH, + "英文": ENGLISH, + "英语": ENGLISH, + "zh": SIMPLIFIED_CHINESE, + "zh_cn": SIMPLIFIED_CHINESE, + "zh_hans": SIMPLIFIED_CHINESE, + "chinese": SIMPLIFIED_CHINESE, + "chinese_(simplified)": SIMPLIFIED_CHINESE, + "simplified_chinese": SIMPLIFIED_CHINESE, + "中文": SIMPLIFIED_CHINESE, + "简体中文": SIMPLIFIED_CHINESE, +} + + +class TranslationCatalogError(RuntimeError): + """Raised when translation catalog files cannot be merged safely.""" + + +def normalize_language(language: Optional[str]) -> str: + """Return a supported stable language code. + + Older display labels and common locale spellings are accepted so existing + settings can be migrated. Unknown values safely fall back to ``system``. + """ + + if not isinstance(language, str): + return SYSTEM_LANGUAGE + key = language.strip().replace("-", "_").replace(" ", "_").casefold() + if key in _LANGUAGE_ALIASES: + return _LANGUAGE_ALIASES[key] + + # Accept common regional/script locale forms and POSIX suffixes when + # migrating settings written by older builds or external launchers. + locale_key = key.split(".", 1)[0].split("@", 1)[0] + if locale_key.startswith("en_"): + return ENGLISH + if locale_key.startswith("zh_"): + return SIMPLIFIED_CHINESE + return SYSTEM_LANGUAGE + + +def resolve_language(language: Optional[str], system_locale: Optional[str] = None) -> str: + """Resolve ``system`` to one of the languages for which AB has a catalog.""" + + normalized = normalize_language(language) + if normalized != SYSTEM_LANGUAGE: + return normalized + + locale_name = system_locale if system_locale is not None else QLocale.system().name() + locale_name = str(locale_name).replace("-", "_").casefold() + # A simplified-Chinese catalog is preferable to an English fallback on a + # Chinese-language system. Other currently unsupported locales use English. + return SIMPLIFIED_CHINESE if locale_name.startswith("zh") else ENGLISH + + +class CatalogTranslator(QTranslator): + """A Qt translator backed by Activity Browser's JSON catalogs.""" + + def __init__(self, catalog: Dict[str, str]): + super().__init__() + self._catalog = catalog + + def translate(self, context, source_text, disambiguation=None, n=-1): + # A null QString (``None`` in PySide) tells Qt to continue to an earlier + # installed translator or its source-text fallback. An empty string + # would instead be treated as a successful blank translation. + return self._catalog.get(source_text) + + +class TranslationManager: + """Load JSON catalogs and install them into a Qt application.""" + + def __init__( + self, + catalog_root: Optional[Union[str, Path]] = None, + system_locale: Optional[str] = None, + ): + self._catalog_root = Path(catalog_root) if catalog_root is not None else None + self._system_locale = system_locale + self._application = None + self._catalog_translator = None + self._qt_translator = None + self._requested_language = SYSTEM_LANGUAGE + self._current_language = resolve_language(SYSTEM_LANGUAGE, self._system_locale) + self._catalog = self.load_catalog(self._current_language) + + @property + def requested_language(self) -> str: + """The stable setting requested by the user, including ``system``.""" + + return self._requested_language + + @property + def current_language(self) -> str: + """The language actually in use (``en_US`` or ``zh_CN``).""" + + return self._current_language + + @property + def catalog(self) -> Dict[str, str]: + """Return a copy of the active catalog for diagnostics and tests.""" + + return dict(self._catalog) + + def _translation_root(self): + if self._catalog_root is not None: + return self._catalog_root + return resources.files("activity_browser.translations") + + def _catalog_files(self, language: str): + root = self._translation_root() + files = [] + + # A single-file catalog is retained for compatibility. New catalog + # contributions should use per-module files in ``There are three ways of doing Contribtion Analysis in Activity Browser: -
- Elementary Flow (EF) Contributions
-- Process Contributions
-- First Tier (FT) Contributions
- - Detailed information on the different approaches provided in this wiki page about the different approaches. - -You can manipulate the results in many ways with Activity Browser, read more on this wiki page - about manipulating results. - """ + self.explain_text = _( + "
- Elementary Flow (EF) Contributions
" + "- Process Contributions
" + "- First Tier (FT) Contributions
" + "Detailed information about these approaches is available on the " + "contribution analysis wiki page.
" + "You can manipulate the results in several ways. See the " + "results manipulation wiki page " + "for details.
" + ) self.help_button = QToolBar(self) self.help_button.addAction( - qicons.question, "Left click for help on Contribution Analysis Functions", self.explanation + qicons.question, + _("Left click for help on Contribution Analysis Functions"), + self.explanation, ) def set_filename(self, optional_fields: dict = None): @@ -1016,6 +1066,12 @@ def set_filename(self, optional_fields: dict = None): filename = "_".join((str(x) for x in fields if x is not None)) self.plot.plot_name, self.table.table_name = filename, filename + @staticmethod + def add_aggregation_items(box: QComboBox, fields: List[str]) -> None: + """Add translated aggregation labels with stable dataframe fields.""" + for field in fields: + box.addItem(_(field), field) + def build_combobox( self, has_method: bool = True, has_func: bool = False ) -> QHBoxLayout: @@ -1086,23 +1142,24 @@ def set_combobox_changes(self): method = self.parent.method_dict[self.combobox_menu.method.currentText()] functional_unit = self.combobox_menu.func.currentText() scenario = max(self.combobox_menu.scenario.currentIndex(), 0) # set scenario 0 if not initiated yet - aggregator = self.combobox_menu.agg.currentText() + aggregator = self.combobox_menu.agg.currentData() # set aggregator to None if unwanted - if aggregator == "none": + if aggregator == NO_AGGREGATION: aggregator = None # initiate dict with the field we want to compare compare_fields = {"aggregator": aggregator} # Determine which comparison is active and update the comparison. - if self.switches.currentIndex() == self.switches.indexes.func: + comparison_mode = self.switches.current_mode + if comparison_mode == self.switches.modes.func: compare_fields.update({"method": method, "scenario": scenario}) - elif self.switches.currentIndex() == self.switches.indexes.method: + elif comparison_mode == self.switches.modes.method: compare_fields.update( {"functional_unit": functional_unit, "scenario": scenario} ) - elif self.switches.currentIndex() == self.switches.indexes.scenario: + elif comparison_mode == self.switches.modes.scenario: compare_fields.update( { "method": method, @@ -1112,7 +1169,9 @@ def set_combobox_changes(self): # Determine the unit for the figure, update the filenames and the # underlying dataframe. - self.unit = get_unit(compare_fields.get("method"), self.relative) + unit_method = compare_fields.get("method") + self.unit = get_unit(unit_method, self.relative) + self.translate_unit = self.relative or not bool(unit_method) self.set_filename(compare_fields) self.df = self.update_dataframe(**compare_fields) @@ -1140,7 +1199,9 @@ def update_dataframe(self, *args, **kwargs): raise NotImplementedError def update_table(self): - super().update_table(self.df, unit=self.unit) + super().update_table( + self.df, unit=self.unit, translate_unit=self.translate_unit + ) def update_plot(self): """Update the plot.""" @@ -1152,7 +1213,9 @@ def update_plot(self): self.plot.deleteLater() self.plot = ContributionPlot(self) self.pt_layout.insertWidget(idx, self.plot) - super().update_plot(self.df, unit=self.unit) + super().update_plot( + self.df, unit=self.unit, translate_unit=self.translate_unit + ) self.plot.plot_name = name if self.pt_layout.parentWidget(): self.pt_layout.parentWidget().updateGeometry() @@ -1180,7 +1243,9 @@ class ElementaryFlowContributionTab(ContributionTab): def __init__(self, parent=None): super().__init__(parent) - header = get_header_layout_w_help("Elementary Flow Contributions", self.help_button) + header = get_header_layout_w_help( + _("Elementary Flow Contributions"), self.help_button + ) self.layout.addLayout(header) self.layout.addWidget(self.cutoff_menu) self.layout.addWidget(horizontal_line()) @@ -1190,7 +1255,7 @@ def __init__(self, parent=None): self.layout.addWidget(self.build_main_space()) self.layout.addLayout(self.build_export(True, True)) - self.contribution_fn = "EF contributions" + self.contribution_fn = _("EF contributions") self.switches.configure(self.has_func, self.has_method) self.connect_signals() self.toggle_comparisons(self.switches.indexes.func) @@ -1198,7 +1263,10 @@ def __init__(self, parent=None): def build_combobox( self, has_method: bool = True, has_func: bool = False ) -> QHBoxLayout: - self.combobox_menu.agg.addItems(self.parent.contributions.DEFAULT_EF_AGGREGATES) + self.add_aggregation_items( + self.combobox_menu.agg, + self.parent.contributions.DEFAULT_EF_AGGREGATES, + ) return super().build_combobox(has_method, has_func) def update_dataframe(self, *args, **kwargs): @@ -1234,7 +1302,7 @@ class ProcessContributionsTab(ContributionTab): def __init__(self, parent=None): super().__init__(parent) - header = get_header_layout_w_help("Process Contributions", self.help_button) + header = get_header_layout_w_help(_("Process Contributions"), self.help_button) self.layout.addLayout(header) self.layout.addWidget(self.cutoff_menu) self.layout.addWidget(horizontal_line()) @@ -1244,7 +1312,7 @@ def __init__(self, parent=None): self.layout.addWidget(self.build_main_space()) self.layout.addLayout(self.build_export(True, True)) - self.contribution_fn = "Process contributions" + self.contribution_fn = _("Process contributions") self.switches.configure(self.has_func, self.has_method) self.connect_signals() self.toggle_comparisons(self.switches.indexes.func) @@ -1252,8 +1320,9 @@ def __init__(self, parent=None): def build_combobox( self, has_method: bool = True, has_func: bool = False ) -> QHBoxLayout: - self.combobox_menu.agg.addItems( - self.parent.contributions.DEFAULT_ACT_AGGREGATES + self.add_aggregation_items( + self.combobox_menu.agg, + self.parent.contributions.DEFAULT_ACT_AGGREGATES, ) return super().build_combobox(has_method, has_func) @@ -1302,7 +1371,9 @@ def __init__(self, cs_name, parent=None): # we also cache scores/ranges, not for calculation speed, but to be able to easily convert for relative results self.caching = True # set to False to disable caching for debug - header = get_header_layout_w_help("First Tier Contributions", self.help_button) + header = get_header_layout_w_help( + _("First Tier Contributions"), self.help_button + ) self.layout.addLayout(header) self.layout.addWidget(self.cutoff_menu) self.layout.addWidget(horizontal_line()) @@ -1322,7 +1393,7 @@ def __init__(self, cs_name, parent=None): ] self.methods = bd.calculation_setups[self.cs]["ia"] - self.contribution_fn = "First Tier contributions" + self.contribution_fn = _("First Tier contributions") self.switches.configure(self.has_func, self.has_method) self.connect_signals() self.toggle_comparisons(self.switches.indexes.func) @@ -1335,8 +1406,9 @@ def update_tab(self): def build_combobox( self, has_method: bool = True, has_func: bool = False ) -> QHBoxLayout: - self.combobox_menu.agg.addItems( - self.parent.contributions.DEFAULT_ACT_AGGREGATES + self.add_aggregation_items( + self.combobox_menu.agg, + self.parent.contributions.DEFAULT_ACT_AGGREGATES, ) return super().build_combobox(has_method, has_func) @@ -1367,7 +1439,7 @@ def calculate(): demand_key = self.func_keys[demand_index] all_data = [] - if compare == "Reference Flows": + if compare == self.switches.modes.func: # run the analysis for every reference flow for demand_index, demand in enumerate(self.func_units): demand_key = self.func_keys[demand_index] @@ -1381,7 +1453,7 @@ def calculate(): if self.caching: self.cache[cache_key] = data all_data.append([demand_key, data]) - elif compare == "Impact Categories": + elif compare == self.switches.modes.method: # run the analysis for every method for method_index, method in enumerate(self.methods): cache_key = (demand_index, method_index, scenario_index) @@ -1395,7 +1467,7 @@ def calculate(): if self.caching: self.cache[cache_key] = data all_data.append([method, data]) - elif compare == "Scenarios": + elif compare == self.switches.modes.scenario: # run the analysis for every scenario for scenario_index in range(self.combobox_menu.scenario.count()): scenario = self.combobox_menu.scenario.itemText(scenario_index) @@ -1543,11 +1615,11 @@ def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: # item is a key, method or scenario depending on the `compares` unique_keys.update(data.keys()) # already add the total with right column formatting depending on `compares` - if compare == "Reference Flows": + if compare == self.switches.modes.func: col_name = self.metadata_to_index(self.key_to_metadata(item)) - elif compare == "Impact Categories": + elif compare == self.switches.modes.method: col_name = self.metadata_to_index(list(item)) - elif compare == "Scenarios": + elif compare == self.switches.modes.scenario: col_name = item self.cache["scores"][col_name] = data["Score"] @@ -1556,10 +1628,15 @@ def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: all_data[i] = item, data, col_name - if compare == "Impact Categories": + if compare == self.switches.modes.method: self.unit = get_unit(method=False, relative=self.relative) + self.translate_unit = True else: - self.unit = get_unit(self.parent.method_dict[self.combobox_menu.method.currentText()], self.relative) + unit_method = self.parent.method_dict[ + self.combobox_menu.method.currentText() + ] + self.unit = get_unit(unit_method, self.relative) + self.translate_unit = self.relative # convert to dict format to feed into dataframe for key in unique_keys: @@ -1586,8 +1663,8 @@ def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: df = df.dropna(subset=data_cols, how="all") # now, apply aggregation - group_on = self.combobox_menu.agg.currentText() - if group_on != "none": + group_on = self.combobox_menu.agg.currentData() + if group_on != NO_AGGREGATION: df = df.groupby(by=group_on, as_index=False).sum() df["index"] = df[group_on] df = df[["index"] + data_cols] @@ -1630,6 +1707,8 @@ def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: score_and_rest = {col: [] for col in df} for col in df: if col == "index": + # Stable values for calculations and exports; the table model + # and plotting copy translate them for display. score_and_rest[col].extend(["Score", "Rest (+)", "Rest (-)"]) elif col in data_cols: # score @@ -1660,7 +1739,7 @@ def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: def update_dataframe(self, *args, **kwargs): """Retrieve the product contributions.""" - compare = self.switches.currentText() + compare = self.switches.current_mode all_data = self.get_data(compare) df = self.data_to_df(all_data, compare) @@ -1672,8 +1751,8 @@ def __init__(self, parent): super().__init__(parent) self.parent = parent - self.tab_text = "Correlations" - self.layout.addLayout(get_header_layout("Correlation Analysis")) + self.tab_text = _("Correlations") + self.layout.addLayout(get_header_layout(_("Correlation Analysis"))) if not self.parent.single_func_unit: self.plot = CorrelationPlot(self.parent) @@ -1709,25 +1788,25 @@ def __init__(self, parent=None): super(MonteCarloTab, self).__init__(parent) self.parent: LCAResultsSubTab = parent header_ = QToolBar() - _header = header("Monte Carlo Simulation") - _header.setToolTip("Left click on the question mark for help") + _header = header(_("Monte Carlo Simulation")) + _header.setToolTip(_("Left click on the question mark for help")) header_.addWidget(_header) header_.addAction( qicons.question, - "Left click for help on Monte Carlo analysis", + _("Left click for help on Monte Carlo analysis"), self.explanation, ) self.layout.addWidget(header_) - self.scenario_label = QLabel("Scenario:") - self.include_box = QGroupBox("Include uncertainty for:", self) + self.scenario_label = QLabel(_("Scenario:")) + self.include_box = QGroupBox(_("Include uncertainty for:"), self) grid = QGridLayout() - self.include_tech = QCheckBox("Technosphere", self) + self.include_tech = QCheckBox(_("Technosphere"), self) self.include_tech.setChecked(True) - self.include_bio = QCheckBox("Biosphere", self) + self.include_bio = QCheckBox(_("Biosphere"), self) self.include_bio.setChecked(True) - self.include_cf = QCheckBox("Characterization Factors", self) + self.include_cf = QCheckBox(_("Characterization Factors"), self) self.include_cf.setChecked(True) - self.include_parameters = QCheckBox("Parameters", self) + self.include_parameters = QCheckBox(_("Parameters"), self) self.include_parameters.setChecked(True) grid.addWidget(self.include_tech, 0, 0) grid.addWidget(self.include_bio, 0, 1) @@ -1738,26 +1817,28 @@ def __init__(self, parent=None): self.add_MC_ui_elements() self.table = LCAResultsTable() - self.table.table_name = "MonteCarlo_" + self.parent.cs_name + self.table.table_name = _("Monte Carlo") + "_" + self.parent.cs_name self.plot = MonteCarloPlot(self.parent) self.plot.hide() - self.plot.plot_name = "MonteCarlo_" + self.parent.cs_name + self.plot.plot_name = _("Monte Carlo") + "_" + self.parent.cs_name self.layout.addWidget(self.plot) self.export_widget = self.build_export(has_plot=True, has_table=True) self.layout.addWidget(self.export_widget) self.layout.setAlignment(QtCore.Qt.AlignTop) self.connect_signals() - self.explain_text = """ -Monte Carlo Analyses
-Monte Carlo simulations generate stochastic data samples using existing data defined parameter - distributions for generating the expected distribution for the reference flows.
-More simply, within the LCA model the user may define certain uncertainty distributions for some - (or all) parameters. Monte Carlo analysis uses these defined uncertainty distributions with a stochastic - generator to sample from these distributions. This results in a "posterior" (or final) probability - distribution, expressing the expected variance, for the reference flows.
-More - information can be found here
- """ + self.explain_text = _( + "Monte Carlo simulations generate stochastic samples from the " + "uncertainty distributions defined in the LCA model and use them to " + "estimate the expected result distribution for each reference flow.
" + "In practical terms, the model can define uncertainty distributions " + "for some or all parameters. The simulation repeatedly samples from " + "those distributions, producing a final probability distribution that " + "describes the expected variability of the reference-flow results.
" + "Read more about Monte Carlo simulation on " + "the wiki.
" + ) def connect_signals(self): self.button_run.clicked.connect(self.calculate_mc_lca) @@ -1784,15 +1865,17 @@ def add_MC_ui_elements(self): layout_mc = QVBoxLayout() # H-LAYOUT start simulation - self.button_run = QPushButton("Run") - self.label_iterations = QLabel("Iterations:") + self.button_run = QPushButton(_("Run")) + self.label_iterations = QLabel(_("Iterations:")) self.iterations = QLineEdit("30") self.iterations.setFixedWidth(40) self.iterations.setValidator(QtGui.QIntValidator(1, 1000)) - self.label_seed = QLabel("Random seed:") + self.label_seed = QLabel(_("Random seed:")) self.label_seed.setToolTip( - "Seed value (integer) for the random number generator. " - "Use this for reproducible samples." + _( + "Seed value (integer) for the random number generator. " + "Use this for reproducible samples." + ) ) self.seed = QLineEdit("") self.seed.setFixedWidth(30) @@ -1837,7 +1920,7 @@ def add_MC_ui_elements(self): # method selection self.method_selection_widget = QWidget() - self.label_methods = QLabel("Choose impact category") + self.label_methods = QLabel(_("Choose impact category")) self.combobox_methods = QComboBox() self.hlayout_methods = QHBoxLayout() @@ -1879,8 +1962,8 @@ def calculate_mc_lca(self): ) QMessageBox.warning( self, - "Warning", - "Seed value must be an integer number or left empty.", + _("Warning"), + _("Seed value must be an integer number or left empty."), ) self.seed.setText("") return @@ -1902,7 +1985,7 @@ def calculate_mc_lca(self): # print(e) log.error(e) QMessageBox.warning( - self, "Could not perform Monte Carlo simulation", str(e) + self, _("Could not perform Monte Carlo simulation"), str(e) ) QApplication.restoreOverrideCursor() @@ -1990,7 +2073,14 @@ def update_mc(self, cs_name=None): self.update_table() self.update_plot(method=method) filename = "_".join( - [str(x) for x in [self.parent.cs_name, "Monte Carlo results", str(method)]] + [ + str(x) + for x in [ + self.parent.cs_name, + _("Monte Carlo results"), + str(method), + ] + ] ) self.plot.plot_name, self.table.table_name = filename, filename @@ -2020,12 +2110,12 @@ def __init__(self, parent=None): self.GSA = GlobalSensitivityAnalysis(self.parent.mc) header_ = QToolBar() - _header = header("Global Sensitivity Analysis") - _header.setToolTip("Left click on the question mark for help") + _header = header(_("Global Sensitivity Analysis")) + _header.setToolTip(_("Left click on the question mark for help")) header_.addWidget(_header) header_.addAction( qicons.question, - "Left click for help on Global Sensitivity Analysis", + _("Left click for help on Global Sensitivity Analysis"), self.explanation, ) @@ -2048,17 +2138,23 @@ def __init__(self, parent=None): self.layout.setAlignment(QtCore.Qt.AlignTop) self.connect_signals() - self.explain_text = """ -Global Sensitivity Analysis (GSA) is a family of methods that, used in conjunction with distribution - generating functions, can investigate the contributions of model variables on the final results.
-Within the AB running a GSA depends on the use of a Monte Carlo simulation for generating the - variable distributions for the reference flow(s), upon which the GSA is performed. Running the GSA executes - the stochastic simulations whilst fixing the values of selected variables of interest. Taking a lower and - upper bound for the variables, therefore, indicates the influence of the fixed variable on the overall - level of model variability.
-For a more detailed explanation see the wiki
-The paper describing the methods is published by Wiley online
- """ + self.explain_text = _( + "Global sensitivity analysis is a family of methods that uses " + "generated distributions to investigate how model variables contribute " + "to the final results.
" + "Activity Browser performs GSA using distributions generated by a " + "Monte Carlo simulation for the reference flows. It repeats the " + "stochastic simulations while fixing selected variables. Comparing the " + "lower and upper bounds then indicates how strongly each fixed variable " + "influences the model's overall variability.
" + "Read the detailed explanation on the " + "wiki.
" + "The methods are described in a " + "" + "published scientific paper.
" + ) def connect_signals(self): self.button_run.clicked.connect(self.calculate_gsa) @@ -2068,15 +2164,15 @@ def add_GSA_ui_elements(self): # H-LAYOUT SETTINGS ROW 1 # run button - self.button_run = QPushButton("Run") + self.button_run = QPushButton(_("Run")) self.button_run.setEnabled(False) # reference flow selection - self.label_fu = QLabel("Reference Flow:") + self.label_fu = QLabel(_("Reference Flow:")) self.combobox_fu = QComboBox() # method selection - self.label_methods = QLabel("Impact Category:") + self.label_methods = QLabel(_("Impact Category:")) self.combobox_methods = QComboBox() # arrange layout @@ -2095,20 +2191,20 @@ def add_GSA_ui_elements(self): self.hlayout_row2 = QHBoxLayout() # cutoff technosphere - self.label_cutoff_technosphere = QLabel("Cut-off technosphere:") + self.label_cutoff_technosphere = QLabel(_("Cut-off technosphere:")) self.cutoff_technosphere = QLineEdit("0.01") self.cutoff_technosphere.setFixedWidth(40) self.cutoff_technosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) # cutoff biosphere - self.label_cutoff_biosphere = QLabel("Cut-off biosphere:") + self.label_cutoff_biosphere = QLabel(_("Cut-off biosphere:")) self.cutoff_biosphere = QLineEdit("0.01") self.cutoff_biosphere.setFixedWidth(40) self.cutoff_biosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) # export GSA input/output data automatically with run self.checkbox_export_data_automatically = QCheckBox( - "Save input/output data to Excel after run" + _("Save input/output data to Excel after run") ) self.checkbox_export_data_automatically.setChecked(False) @@ -2134,7 +2230,7 @@ def add_GSA_ui_elements(self): # add to GSA layout self.label_monte_carlo_first = QLabel( - "You need to run a Monte Carlo Simulation first." + _("You need to run a Monte Carlo Simulation first.") ) self.layout.addWidget(self.label_monte_carlo_first) self.layout.addWidget(self.widget_settings) @@ -2178,16 +2274,22 @@ def calculate_gsa(self): message = str(e) message_addition = "" if message == "singular matrix": - message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." + message_addition = _( + "\nTo avoid this problem, increase the number of Monte Carlo " + "iterations (for example, to more than 50)." + ) elif message == "`dataset` input should have multiple elements.": - message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." + message_addition = _( + "\nTo avoid this problem, increase the number of Monte Carlo " + "iterations (for example, to more than 50)." + ) elif message == "No objects to concatenate": - message_addition = ( + message_addition = _( "\nThe reason for this is likely that there are no uncertain exchanges. Please check " "the checkboxes in the Monte Carlo tab." ) QMessageBox.warning( - self, "Could not perform GSA", str(message) + message_addition + self, _("Could not perform GSA"), str(message) + message_addition ) QApplication.restoreOverrideCursor() @@ -2201,7 +2303,7 @@ def update_gsa(self, cs_name=None): self.table.show() self.export_widget.show() - self.table.table_name = "gsa_output_" + self.GSA.get_save_name() + self.table.table_name = _("GSA output") + "_" + self.GSA.get_save_name() if self.checkbox_export_data_automatically.isChecked(): log.info("EXPORTING DATA") diff --git a/activity_browser/layouts/tabs/LCA_setup.py b/activity_browser/layouts/tabs/LCA_setup.py index ff10c5678..5c5bbbdc6 100644 --- a/activity_browser/layouts/tabs/LCA_setup.py +++ b/activity_browser/layouts/tabs/LCA_setup.py @@ -6,6 +6,7 @@ from PySide2.QtCore import Qt, Slot from activity_browser import actions, signals +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from ...bwutils.errors import * @@ -117,12 +118,12 @@ def __init__(self, parent=None): self.list_widget.currentText ) - self.calculate_button = QtWidgets.QPushButton(qicons.calculate, "Calculate") + self.calculate_button = QtWidgets.QPushButton(qicons.calculate, _("Calculate")) self.calculation_type = QtWidgets.QComboBox() - self.calculation_type.addItems(["Standard LCA", "Scenario LCA"]) + self.calculation_type.addItems([_("Standard LCA"), _("Scenario LCA")]) name_row = QtWidgets.QHBoxLayout() - name_row.addWidget(header("Calculation Setup:")) + name_row.addWidget(header(_("Calculation setup:"))) name_row.addWidget(self.list_widget) name_row.addWidget(self.new_cs_button) name_row.addWidget(self.duplicate_cs_button) @@ -143,14 +144,14 @@ def __init__(self, parent=None): # widget for the reference flows self.reference_flow_widget = QtWidgets.QWidget() reference_flow_layout = QtWidgets.QVBoxLayout() - reference_flow_layout.addWidget(header("Reference flows:")) + reference_flow_layout.addWidget(header(_("Reference flows:"))) reference_flow_layout.addWidget(self.activities_table) self.reference_flow_widget.setLayout(reference_flow_layout) # widget for the impact categories self.impact_categories_widget = QtWidgets.QWidget() impact_categories_layout = QtWidgets.QVBoxLayout() - impact_categories_layout.addWidget(header("Impact categories:")) + impact_categories_layout.addWidget(header(_("Impact categories:"))) impact_categories_layout.addWidget(self.methods_table) self.impact_categories_widget.setLayout(impact_categories_layout) @@ -160,7 +161,7 @@ def __init__(self, parent=None): self.splitter.addWidget(self.impact_categories_widget) self.no_setup_label = QtWidgets.QLabel( - "To do an LCA, create a new calculation setup first by pressing 'New'." + _("Create a calculation setup with New before running an LCA.") ) cs_panel_layout.addWidget(self.no_setup_label) cs_panel_layout.addWidget(self.splitter) @@ -270,53 +271,39 @@ class ScenarioImportPanel(BaseRightTab): def __init__(self, parent=None): super().__init__(parent) - self.explain_text = """ -You can import two types of scenario files here: -
1. Flow-scenarios: alternative values for exchanges (technosphere/biosphere flows) - (scenario difference files)
-2. Parameter-scenarios: alternative values for parameters (parameter scenarios files)
- - Further information is provided on this wiki page for - Flow Scenarios and - Parameter Scenarios. - -If you need a template for these files, you can go to the Parameters > Scenarios tab. - Then click Export parameter-scenarios to obtain a parameter-scenarios file or - Export as flow-scenarios to obtain a flow-scenarios file - (you need at least one parameterized activity for the latter).
- -You can also work with multiple scenario files for which there are with two options:
-1. Combine scenarios: this yields all possible scenario combinations - (e.g. file 1: S1, S2 and file 2: A, B yields S1-A, S1-B, S2-A, S2-B) - Click here - for an example
-2. Extend scenarios: scenarios from file 2 extend scenarios of file 1 - (only possible if scenario names are identical in all files, e.g. everywhere S1, S2). - Click here - for an example
- """ + self.explain_text = _( + "You can import two types of scenario files:
" + "1. Flow scenarios: alternative exchange values for " + "technosphere or biosphere flows (scenario difference files).
" + "2. Parameter scenarios: alternative parameter values.
" + "Templates can be exported from Parameters > Scenarios. " + "See the Activity Browser wiki for details.
" + "With multiple files, Combine scenarios creates every " + "combination; Extend scenarios joins values for identically " + "named scenarios across files.
" + ) self.tables = [] self._scenario_dataframe = pd.DataFrame() # set-up the header - panel_header = header("Scenarios: ") - panel_header.setToolTip("Left click on the question mark for help") + panel_header = header(_("Scenarios:")) + panel_header.setToolTip(_("Click the question mark for help.")) # set-up the control buttons - self.table_btn = QtWidgets.QPushButton("Add scenarios", self) + self.table_btn = QtWidgets.QPushButton(_("Add scenarios"), self) - self.save_scenario = QtWidgets.QPushButton("Save to file...", self) + self.save_scenario = QtWidgets.QPushButton(_("Save to file..."), self) self.save_scenario.setDisabled(True) # set-up the combination buttons # initiate the combine scenarios button - self.product_choice = QtWidgets.QRadioButton("Combine scenarios", self) + self.product_choice = QtWidgets.QRadioButton(_("Combine scenarios"), self) self.product_choice.setChecked(True) # initiate the extend scenarios button - self.addition_choice = QtWidgets.QRadioButton("Extend scenarios", self) + self.addition_choice = QtWidgets.QRadioButton(_("Extend scenarios"), self) # group them and make them exclusive self.combine_group = QtWidgets.QButtonGroup(self) @@ -338,7 +325,7 @@ def __init__(self, parent=None): # set-up the help button help_button = QtWidgets.QToolBar(self) help_button.addAction( - qicons.question, "Left click for help on Scenarios", self.explanation + qicons.question, _("Help for scenarios"), self.explanation ) # combining all into the tool row @@ -349,7 +336,7 @@ def __init__(self, parent=None): tool_row.addWidget(self.save_scenario) tool_row.addWidget(self.group_box) tool_row.addStretch(1) - tool_row.addWidget(QtWidgets.QLabel("More info on scenarios: ")) + tool_row.addWidget(QtWidgets.QLabel(_("More information:"))) tool_row.addWidget(help_button) # layout for the different scenario tables that can be added @@ -386,7 +373,12 @@ def update_stats(self) -> None: n_scenarios = len(self._scenario_dataframe.columns) n_flows = len(self._scenario_dataframe) - stats = f"Total number of scenarios: {n_scenarios} | Total number of variable flows: {n_flows}" + stats = _( + "Total scenarios: {scenario_count} | Variable flows: " + "{flow_count}", + scenario_count=n_scenarios, + flow_count=n_flows, + ) self.stats_widget.setText(stats) def toggle_combine_type(self) -> None: @@ -513,10 +505,10 @@ def save_action(self) -> None: Triggered by a signal from ScenarioImportPanel save button, uses a dummy input argument. """ - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + filepath, _selected_filter = QtWidgets.QFileDialog.getSaveFileName( parent=self, - caption="Choose location to save the scenario file", - filter="Excel (*.xlsx *.xls);; CSV (*.csv)", + caption=_("Choose where to save the scenario file"), + filter=_("Excel (*.xlsx *.xls);;CSV (*.csv)"), ) print("Saving scenario dataframe to file: ", filepath) scenarios = self._scenario_dataframe.columns.difference( @@ -552,11 +544,11 @@ def __init__(self, index: int, parent=None): super().__init__(parent) self._parent = parent self.index = index - self.scenario_name = QtWidgets.QLabel("During the attempted import" - " another file type was detected. Please check the file type of the attempted import, if it is" - " a scenario file make sure it contains a valid format.
" - "A flow exchange scenario file requires the following headers:
"
+ _(
+ "Activity Browser expected a scenario file, but detected "
+ "another file type or an invalid format.
A flow scenario "
+ "file requires these headers:
"
+ )
+ edit_superstructure_for_string(sep=", ", fhighlight='"')
- + "
A parameter scenario file requires the following:
"
+ + _(
+ "
A parameter scenario file requires these "
+ "headers:
"
+ )
+ edit_superstructure_for_string(
["name", "group"], sep=", ", fhighlight='"'
)
+ "
This tab is the main tab for creating and modifying parameters.
-The scope of parameters can be either a specific activity, a database, or an entire project -(meaning that an activity parameter can only be used within a specific activity, -while a project parameter can be used anywhere within a project and across all databases within that project).
- - - -In general
-All parameters must have a name and amount. A formula is optional.
-The formula is stored as a string that is interpreted by brightway. Python builtin functions and Numpy functions -can be used within the formula!
-Parameters can only be deleted if they are not used in formulas of other parameters.
-Note that optionally uncertainties, can be specified for parameters.
- -Activity parameters
-New parameters are added either by drag-and-dropping activities from the database table or by adding - a formula to an activity exchange within the Activity tab.
-For more information on this topic see also the -Brightway2 documentation.
-""" + self.explain_text = _( + "Create and modify project, database, and activity parameters " + "here. A parameter must have a name and amount; a formula and " + "uncertainty are optional.
Project parameters are available " + "throughout the project, while database and activity parameters " + "have narrower scopes. Parameters used by another formula cannot " + "be deleted.
Create activity parameters by dragging an " + "activity from the database table or by adding a formula to an " + "exchange. Only editable databases can be parameterized.
" + ) def _connect_signals(self): qprojects.current_changed.connect(self.build_tables) @@ -243,8 +231,8 @@ def _construct_layout(self): self.uncertainty_columns.setChecked(False) row = QToolBar() - _header = header("Parameters ") - _header.setToolTip("Left click on the question mark for help") + _header = header(_("Parameters")) + _header.setToolTip(_("Click the question mark for help.")) row.addWidget(_header) row.addWidget(self.show_database_params) row.addWidget(self.show_activity_params) @@ -252,7 +240,7 @@ def _construct_layout(self): row.addWidget(self.uncertainty_columns) row.addAction( qicons.question, - "Left click for help on brightway parameters", + _("Help for Brightway parameters"), self.explanation, ) layout.addWidget(row) @@ -351,14 +339,12 @@ def __init__(self, parent=None): self._construct_layout() self._connect_signals() - self.explain_text = """ -This tab lists all exchanges within the selected project that are calculated via parameters.
-The Project level parameters are shown above the database and activity parameters.
-To see the different database and activity parameters in the Project click on the arrows to expand the trees
- -For more information on this topic see also the -Brightway2 documentation.
-""" + self.explain_text = _( + "This tab lists exchanges in the selected project whose values " + "are calculated from parameters.
Project parameters appear " + "above database and activity parameters. Expand the tree to inspect " + "their dependent exchanges.
" + ) def _connect_signals(self): qprojects.current_changed.connect(self.build_tables) @@ -368,12 +354,12 @@ def _construct_layout(self): """Construct the widget layout for the exchanges parameters tab""" layout = QVBoxLayout() row = QToolBar() - _header = header("Overview of parameterized exchanges") - _header.setToolTip("Left click on the question mark for help") + _header = header(_("Overview of parameterized exchanges")) + _header.setToolTip(_("Click the question mark for help.")) row.addWidget(_header) row.setIconSize(QSize(24, 24)) row.addAction( - qicons.question, "Left click for help on parameters", self.explanation + qicons.question, _("Help for parameters"), self.explanation ) layout.addWidget(row) layout.addWidget(horizontal_line()) @@ -389,54 +375,45 @@ class ParameterScenariosTab(BaseRightTab): def __init__(self, parent=None): super().__init__(parent) - self.load_btn = QPushButton(qicons.add, "Import parameter-scenarios") + self.load_btn = QPushButton(qicons.add, _("Import parameter scenarios")) self.load_btn.setToolTip( - "Load prepared excel files with additional parameter scenarios." + _("Load an Excel file containing additional parameter scenarios.") ) self.save_btn = QPushButton( self.style().standardIcon(QStyle.SP_DialogSaveButton), - "Export parameter-scenarios", + _("Export parameter scenarios"), ) self.save_btn.setToolTip( - "Export the current parameter scenario table to excel." + _("Export the current parameter scenario table to Excel.") ) - self.calculate_btn = QPushButton(qicons.calculate, "Export as flow-scenarios") + self.calculate_btn = QPushButton(qicons.calculate, _("Export as flow scenarios")) self.calculate_btn.setToolTip( ( - "Process the current parameter scenario table into prepared flow" - " scenario data." + _( + "Convert the current parameter scenario table into prepared " + "flow scenario data." + ) ) ) - self.reset_btn = QPushButton(qicons.history, "Reset table") - self.reset_btn.setToolTip("Reset the scenario table, wiping any changes.") - self.hide_group = QCheckBox("Show group column") + self.reset_btn = QPushButton(qicons.history, _("Reset table")) + self.reset_btn.setToolTip(_("Reset the scenario table and discard changes.")) + self.hide_group = QCheckBox(_("Show group column")) self.tbl = ScenarioTable(self) self.tbl.setToolTip( - "This table is not editable, use the export/import functionality" + _("This table is read-only. Use the import and export functions.") ) self._construct_layout() self._connect_signals() - self.explain_text = """ -This tab has 3 functions:
-1. Export parameter-scenarios : this exports the table as shown below to an Excel file. You can modify it there and use - it in scenario LCAs (see Calculation Setup tab)
-2. Import parameter-scenarios: imports a table like the one shown below from Excel. If parameters are missing in Excel, - the default values will be used. IMPORTANT NOTE: the ONLY function this button serves is to display the Excel file. - If you want to use the Excel file in scenario LCA, please import it in the Calculation Setup tab.
-3. Export as flow-scenarios: This converts a "parameter-scenarios" file (alternative values for parameters) to a - "flow-scenarios" file (alternative values for the exchanges as used in LCA calculations).
- -Suggested workflow to create scenarios for your parameters:
-Export parameter-scenarios. This will generate an Excel file for you where you can add scenarios (columns). - You may want to delete rows that you intend to change or rows that are for dependent parameters (those that depend on other parameters) as these values will be overwritten by the formulas. - Finally, import the parameter-scenarios in the Calculation Setup (not here!) to perform scenario calculations (you need to select "Scenario LCA").
- -For more information on this topic see also the - Brightway2 documentation.
- """ + self.explain_text = _( + "Export parameter scenarios to Excel, edit them, and import " + "them here for inspection. To use the file in a scenario LCA, " + "import it in Calculation Setup.
Export as flow scenarios " + "converts alternative parameter values into alternative exchange " + "values used in LCA calculations.
" + ) def _connect_signals(self): self.load_btn.clicked.connect(self.select_read_file) @@ -450,12 +427,12 @@ def _construct_layout(self): layout = QVBoxLayout() row = QToolBar() - _header = header("Parameter Scenarios") - _header.setToolTip("Click on the question mark for help") + _header = header(_("Parameter scenarios")) + _header.setToolTip(_("Click the question mark for help.")) row.addWidget(_header) row.addAction( qicons.question, - "Left click for help on parameters scenarios", + _("Help for parameter scenarios"), self.explanation, ) layout.addWidget(row) @@ -483,15 +460,32 @@ def process_scenarios( self.tbl.model.sync(df=df, include_default=default) scenarios = self.build_flow_scenarios() signals.parameter_superstructure_built.emit(table_idx, scenarios) + except TooManyParametersError: + QMessageBox.critical( + self, + _("Cannot load parameters"), + _( + "The scenario file contains more parameter rows than the " + "current project." + ), + QMessageBox.Ok, + QMessageBox.Ok, + ) except AssertionError as e: QMessageBox.critical( - self, "Cannot load parameters", str(e), QMessageBox.Ok, QMessageBox.Ok + self, + _("Cannot load parameters"), + str(e), + QMessageBox.Ok, + QMessageBox.Ok, ) @Slot(name="loadSenarioTable") def select_read_file(self): - path, _ = QFileDialog.getOpenFileName( - self, caption="Select prepared scenario file", filter=self.tbl.EXCEL_FILTER + path, _selected_filter = QFileDialog.getOpenFileName( + self, + caption=_("Select a prepared scenario file"), + filter=self.tbl.EXCEL_FILTER, ) if path: df = pd.read_excel(path, engine="openpyxl") @@ -500,13 +494,17 @@ def select_read_file(self): @Slot(name="saveScenarioTable") def save_scenarios(self): try: - self.tbl.to_excel("Save current scenarios to Excel") + self.tbl.to_excel(_("Save current scenarios to Excel")) except FileCreateError as e: QMessageBox.warning( self, - "File save error", - "Cannot save the file, please see if it is opened elsewhere or " - "if you are allowed to save files in that location:\n\n{}".format(e), + _("File save error"), + _( + "The file could not be saved. It may be open elsewhere, or " + "you may not have permission to save in that location.\n\n" + "{error}", + error=e, + ), QMessageBox.Ok, QMessageBox.Ok, ) @@ -529,9 +527,9 @@ def build_flow_scenarios(self) -> pd.DataFrame: return df def store_flows_to_file(self, df: pd.DataFrame) -> None: - filename, _ = QFileDialog.getSaveFileName( + filename, _selected_filter = QFileDialog.getSaveFileName( self, - caption="Save calculated flow scenarios to Excel", + caption=_("Save calculated flow scenarios to Excel"), filter=self.tbl.EXCEL_FILTER, ) if filename: @@ -546,10 +544,12 @@ def store_flows_to_file(self, df: pd.DataFrame) -> None: except FileCreateError as e: QMessageBox.warning( self, - "File save error", - "Cannot save the file, please see if it is opened elsewhere or " - "if you are allowed to save files in that location:\n\n{}".format( - e + _("File save error"), + _( + "The file could not be saved. It may be open elsewhere, or " + "you may not have permission to save in that location.\n\n" + "{error}", + error=e, ), QMessageBox.Ok, QMessageBox.Ok, diff --git a/activity_browser/layouts/tabs/project_manager.py b/activity_browser/layouts/tabs/project_manager.py index 524518afe..1ffe9dc5f 100644 --- a/activity_browser/layouts/tabs/project_manager.py +++ b/activity_browser/layouts/tabs/project_manager.py @@ -1,6 +1,7 @@ from PySide2 import QtCore, QtWidgets from activity_browser import actions, signals +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from activity_browser.layouts.panels import ABTab @@ -70,7 +71,7 @@ def construct_layout(self): h_widget = QtWidgets.QWidget() h_layout = QtWidgets.QHBoxLayout() h_layout.setAlignment(QtCore.Qt.AlignLeft) - h_layout.addWidget(header("Project:")) + h_layout.addWidget(header(_("Project:"))) h_layout.addWidget(self.projects_list) h_layout.addWidget(self.new_project_button) h_layout.addWidget(self.copy_project_button) @@ -94,12 +95,14 @@ class DatabaseWidget(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) self.table = DatabasesTable() - self.table.setToolTip("To select a database, double-click on an entry") + self.table.setToolTip(_("Double-click an entry to select a database.")) # Temporary inclusion to explain things before checkbox is back self.label_change_readonly = QtWidgets.QLabel( - "To change a database from read-only to editable and back," - + " click on the checkbox in the table." + _( + "Use the checkbox in the table to switch a database between " + "read-only and editable." + ) ) # Buttons @@ -119,7 +122,7 @@ def _construct_layout(self): header_widget = QtWidgets.QWidget() header_layout = QtWidgets.QHBoxLayout() header_layout.setAlignment(QtCore.Qt.AlignLeft) - header_layout.addWidget(header("Databases:")) + header_layout.addWidget(header(_("Databases:"))) header_layout.addWidget(self.add_default_data_button) header_layout.addWidget(self.new_database_button) header_layout.addWidget(self.import_database_button) @@ -203,12 +206,12 @@ def __init__(self, parent, db_name: str): self.setup_search() self.search_active = False - self.mode_radio_list = QtWidgets.QRadioButton("List view") + self.mode_radio_list = QtWidgets.QRadioButton(_("List view")) self.mode_radio_list.setChecked(True) - self.mode_radio_list.setToolTip("List view of the database") + self.mode_radio_list.setToolTip(_("List view of the database")) self.mode_radio_list.hide() - self.mode_radio_tree = QtWidgets.QRadioButton("Tree view") - self.mode_radio_tree.setToolTip("Tree view of the database") + self.mode_radio_tree = QtWidgets.QRadioButton(_("Tree view")) + self.mode_radio_tree.setToolTip(_("Tree view of the database")) self.mode_radio_tree.hide() self.mode_radio_tree.toggled.connect(self.update_view) @@ -246,20 +249,20 @@ def reset_widget(self): def setup_search(self): # 1st search box self.search_box = QtWidgets.QLineEdit() - self.search_box.setPlaceholderText("Search") + self.search_box.setPlaceholderText(_("Search")) self.search_box.textChanged.connect(self.debounce_search.start) self.search_box.returnPressed.connect(self.set_search_term) # search self.search_button = QtWidgets.QToolButton() self.search_button.setIcon(qicons.search) - self.search_button.setToolTip("Filter activities") + self.search_button.setToolTip(_("Filter activities")) self.search_button.clicked.connect(self.set_search_term) # reset search self.reset_search_button = QtWidgets.QToolButton() self.reset_search_button.setIcon(qicons.delete) - self.reset_search_button.setToolTip("Clear the search") + self.reset_search_button.setToolTip(_("Clear the search")) self.reset_search_button.clicked.connect(self.table.reset_search) self.reset_search_button.clicked.connect(self.search_box.clear) diff --git a/activity_browser/mod/bw2io/__init__.py b/activity_browser/mod/bw2io/__init__.py index cd9cabe6c..3566c1352 100644 --- a/activity_browser/mod/bw2io/__init__.py +++ b/activity_browser/mod/bw2io/__init__.py @@ -3,6 +3,7 @@ from bw2io import * from activity_browser.info import __ei_versions__ +from activity_browser.i18n import _ from activity_browser.utils import sort_semantic_versions log = getLogger(__name__) @@ -18,18 +19,18 @@ def ab_bw2setup(version): version = version[:3] if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.info(f"Installing biosphere version >{version}<") + log.info(_("Installing biosphere version {version}", version=version)) # most recent version bio_import = ABEcospold2BiosphereImporter() else: - log.info(f"Installing legacy biosphere version >{version}<") + log.info(_("Installing legacy biosphere version {version}", version=version)) # not most recent version, import legacy biosphere from AB bio_import = ABEcospold2BiosphereImporter(version=version) bio_import.apply_strategies() - log.info("Writing biosphere database") + log.info(_("Writing biosphere database")) bio_import.write_database() - log.info("Writing LCIA methods") + log.info(_("Writing LCIA methods")) create_default_lcia_methods() # patching biosphere @@ -47,7 +48,6 @@ def ab_bw2setup(version): ] for patch in patches: - log.info(f"Applying biosphere patch: {patch}") + log.info(_("Applying biosphere patch: {patch}", patch=patch)) update_bio = getattr(bi.data, patch) update_bio() - diff --git a/activity_browser/mod/bw2io/ecoinvent.py b/activity_browser/mod/bw2io/ecoinvent.py index 5a2e64b08..ce7aea88f 100644 --- a/activity_browser/mod/bw2io/ecoinvent.py +++ b/activity_browser/mod/bw2io/ecoinvent.py @@ -4,6 +4,7 @@ import pyprind +from activity_browser.i18n import _ from activity_browser.mod.ecoinvent_interface.release import ABEcoinventRelease from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter @@ -32,27 +33,27 @@ def ab_import_ecoinvent_release(version, system_model): name="biosphere3", filepath=lci_path / "MasterData" / "ElementaryExchanges.xml", ) - log.info("Applying strategies") + log.info(_("Applying strategies")) bio_import.apply_strategies() - log.info("Writing biosphere database") + log.info(_("Writing biosphere database")) bio_import.write_database() bd.preferences["biosphere_database"] = "biosphere3" # importing ecoinvent through a ecospold2 importer that implements a progress_slot - log.info("Importing ecoinvent") + log.info(_("Importing ecoinvent")) db_name = f"ecoinvent-{version}-{system_model}" ei_import = SingleOutputEcospold2Importer( dirpath=str(lci_path / "datasets"), db_name=db_name, biosphere_database_name="biosphere3", ) - log.info("Applying strategies") + log.info(_("Applying strategies")) ei_import.apply_strategies() - log.info("Writing ecoinvent database") + log.info(_("Writing ecoinvent database")) ei_import.write_database() # importing all LCIA methods - log.info("Gathering LCIA methods") + log.info(_("Gathering LCIA methods")) lcia_file = ei.get_excel_lcia_file_for_version(release=release, version=version) sheet_names = get_excel_sheet_names(lcia_file) @@ -69,11 +70,11 @@ def ab_import_ecoinvent_release(version, system_model): raise ValueError( f"Can't find worksheet for characterization factors; expected `CFs`, found {sheet_names}" ) - log.info("Extracting LCIA methods") + log.info(_("Extracting LCIA methods")) data = dict(ExcelExtractor.extract(lcia_file)) units = header_dict(data[units_sheetname]) - log.info("Mapping LCIA methods") + log.info(_("Mapping LCIA methods")) cfs = header_dict(data["CFs"]) CF_COLUMN_LABELS = { @@ -158,7 +159,7 @@ def ab_import_ecoinvent_release(version, system_model): ) unmatched.add(row["name"]) - for key in pyprind.prog_bar(lcia_data_as_dict, title="Writing LCIA methods"): + for key in pyprind.prog_bar(lcia_data_as_dict, title=_("Writing LCIA methods")): method = bd.Method(key) method.register( unit=units_mapping.get(key, "Unknown"), diff --git a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py index f8d9fb065..936b2831e 100644 --- a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py +++ b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py @@ -2,6 +2,8 @@ from bw2io.importers.ecospold2_biosphere import * import pyprind + +from activity_browser.i18n import _ import logging import os @@ -90,7 +92,9 @@ def extract_flow_data(o): flow_data = [] # AB implementation: added prog_bar here - for ds in pyprind.prog_bar(list(root.iterchildren()), title="Extracting biosphere data"): + for ds in pyprind.prog_bar( + list(root.iterchildren()), title=_("Extracting biosphere data") + ): flow_data.append(extract_flow_data(ds)) return flow_data @@ -107,9 +111,9 @@ def apply_strategies(self, strategies=None, verbose=True): """ func_list = self.strategies if strategies is None else strategies - for func in pyprind.prog_bar(func_list, title="Applying strategies"): + for func in pyprind.prog_bar(func_list, title=_("Applying strategies")): self.apply_strategy(func, verbose) def write_database(self, *args, **kwargs): - logging.getLogger(__name__).info("Writing Biosphere database") + logging.getLogger(__name__).info(_("Writing biosphere database")) super().write_database(*args, **kwargs) diff --git a/activity_browser/mod/bw2io/migrations.py b/activity_browser/mod/bw2io/migrations.py index 63d34d009..44470b934 100644 --- a/activity_browser/mod/bw2io/migrations.py +++ b/activity_browser/mod/bw2io/migrations.py @@ -2,85 +2,94 @@ from pyprind import ProgBar +from activity_browser.i18n import _ + + +def _migration_title(name): + """Return a translated progress label while preserving the migration ID.""" + + return _("Creating migration: {migration}", migration=name) + + def ab_create_core_migrations(): """Activity Browser version of bw2io.migrations.create_core_migrations that employs a progress slot""" - bar = ProgBar(12, title="Creating migrations") + bar = ProgBar(12, title=_("Creating migrations")) - bar.title = "Creating migration: biosphere-2-3-categories" + bar.title = _migration_title("biosphere-2-3-categories") bar.update(0) Migration("biosphere-2-3-categories").write( get_biosphere_2_3_category_migration_data(), "Change biosphere category and subcategory labels to ecoinvent version 3", ) - bar.title = "Creating migration: biosphere-2-3-names" + bar.title = _migration_title("biosphere-2-3-names") bar.update() Migration("biosphere-2-3-names").write( get_biosphere_2_3_name_migration_data(), "Change biosphere flow names to ecoinvent version 3", ) - bar.title = "Creating migration: simapro-ecoinvent-3.1" + bar.title = _migration_title("simapro-ecoinvent-3.1") bar.update() Migration("simapro-ecoinvent-3.1").write( get_simapro_ecoinvent_3_migration_data("3.1"), "Change SimaPro names from ecoinvent 3.1 to ecoinvent names", ) - bar.title = "Creating migration: simapro-ecoinvent-3.2" + bar.title = _migration_title("simapro-ecoinvent-3.2") bar.update() Migration("simapro-ecoinvent-3.2").write( get_simapro_ecoinvent_3_migration_data("3.2"), "Change SimaPro names from ecoinvent 3.2 to ecoinvent names", ) - bar.title = "Creating migration: simapro-ecoinvent-3.3" + bar.title = _migration_title("simapro-ecoinvent-3.3") bar.update() Migration("simapro-ecoinvent-3.3").write( get_simapro_ecoinvent_3_migration_data("3.3"), "Change SimaPro names from ecoinvent 3.3 to ecoinvent names", ) - bar.title = "Creating migration: simapro-ecoinvent-3.4" + bar.title = _migration_title("simapro-ecoinvent-3.4") bar.update() Migration("simapro-ecoinvent-3.4").write( get_simapro_ecoinvent_3_migration_data("3.4"), "Change SimaPro names from ecoinvent 3.4 to ecoinvent names", ) - bar.title = "Creating migration: simapro-water" + bar.title = _migration_title("simapro-water") bar.update() Migration("simapro-water").write( get_simapro_water_migration_data(), "Change SimaPro water flows to more standard names", ) - bar.title = "Creating migration: us-lci" + bar.title = _migration_title("us-lci") bar.update() Migration("us-lci").write( get_us_lci_migration_data(), "Fix names in US LCI database" ) - bar.title = "Creating migration: default-units" + bar.title = _migration_title("default-units") bar.update() Migration("default-units").write( get_default_units_migration_data(), "Convert to default units" ) - bar.title = "Creating migration: unusual-units" + bar.title = _migration_title("unusual-units") bar.update() Migration("unusual-units").write( get_unusual_units_migration_data(), "Convert non-Ecoinvent units" ) - bar.title = "Creating migration: exiobase-biosphere" + bar.title = _migration_title("exiobase-biosphere") bar.update() Migration("exiobase-biosphere").write( get_exiobase_biosphere_migration_data(), "Change biosphere flow names to ecoinvent version 3", ) - bar.title = "Creating migration: fix-ecoinvent-flows-pre-35" + bar.title = _migration_title("fix-ecoinvent-flows-pre-35") bar.update() Migration("fix-ecoinvent-flows-pre-35").write( get_ecoinvent_pre35_migration_data(), diff --git a/activity_browser/mod/ecoinvent_interface/release.py b/activity_browser/mod/ecoinvent_interface/release.py index 04abff80f..44826151f 100644 --- a/activity_browser/mod/ecoinvent_interface/release.py +++ b/activity_browser/mod/ecoinvent_interface/release.py @@ -3,6 +3,8 @@ import pyprind +from activity_browser.i18n import _ + class ABEcoinventRelease(EcoinventRelease): @@ -30,7 +32,7 @@ def _streaming_download( chunk = 128 * 1024 size = int(response.headers["Content-Length"]) - dl_bar = pyprind.ProgBar(size, title="Downloading from ecoinvent") + dl_bar = pyprind.ProgBar(size, title=_("Downloading from ecoinvent")) while True: segment = download.read(chunk) @@ -50,7 +52,7 @@ def _streaming_download( """ logger.debug(message) - logger.info("Unzipping download") + logger.info(_("Unzipping download")) if zipped: with open(out_filepath, "rb") as source, open( @@ -95,7 +97,11 @@ def get_release( )[0] if possible[0] <= 3: logger.info( - f"Using close match {possible[1]} for predicted filename {filename}" + _( + "Using close match {match} for predicted filename {filename}", + match=possible[1], + filename=filename, + ) ) filename = possible[1] else: @@ -124,19 +130,23 @@ def get_release( if fix_version and release_type in SPOLD_FILES and not cached: major, minor = major_minor_from_string(version) if (result_path / "datasets").is_dir(): - logger.info("Fixing versions in unit process datasets") + logger.info(_("Fixing versions in unit process datasets")) - for filepath in pyprind.prog_bar(list((result_path / "datasets").iterdir()), - title="Fixing versions in unit process data"): + for filepath in pyprind.prog_bar( + list((result_path / "datasets").iterdir()), + title=_("Fixing versions in unit process data"), + ): if not filepath.suffix.lower() == ".spold": continue fix_version_upr( filepath=filepath, major_version=major, minor_version=minor ) if (result_path / "MasterData").is_dir(): - logger.info("Fixing versions in master data") - for filepath in pyprind.prog_bar(list((result_path / "MasterData").iterdir()), - title="Fixing versions in master data"): + logger.info(_("Fixing versions in master data")) + for filepath in pyprind.prog_bar( + list((result_path / "MasterData").iterdir()), + title=_("Fixing versions in master data"), + ): if not filepath.suffix.lower() == ".xml": continue fix_version_meta( diff --git a/activity_browser/settings.py b/activity_browser/settings.py index b6aa61c16..f1950bd34 100644 --- a/activity_browser/settings.py +++ b/activity_browser/settings.py @@ -9,6 +9,7 @@ import appdirs from PySide2.QtWidgets import QMessageBox +from activity_browser.i18n import _, SYSTEM_LANGUAGE, normalize_language from activity_browser.signals import signals from activity_browser.mod import bw2data as bd @@ -45,12 +46,18 @@ def initialize_settings(self) -> None: self.write_settings() def load_settings(self) -> None: - with open(self.settings_file, "r") as infile: + with open(self.settings_file, "r", encoding="utf-8") as infile: self.settings = json.load(infile) def write_settings(self) -> None: - with open(self.settings_file, "w") as outfile: - json.dump(self.settings, outfile, indent=4, sort_keys=True) + with open(self.settings_file, "w", encoding="utf-8") as outfile: + json.dump( + self.settings, + outfile, + ensure_ascii=False, + indent=4, + sort_keys=True, + ) class ABSettings(BaseSettings): @@ -74,9 +81,32 @@ def __init__(self, filename: str): super().__init__(ab_dir.user_data_dir, filename) if not self.healthy(): - log.warn("Settings health check failed, resetting") + log.warning("Settings health check failed, resetting") self.restore_default_settings() + self.migrate_settings() + + def migrate_settings(self) -> None: + """Migrate interface settings to their stable stored representation.""" + + stored_language = self.settings.get( + "language", + self.settings.get("ui_language", self.settings.get("locale")), + ) + language = normalize_language(stored_language) + changed = self.settings.get("language") != language + + # These keys were used by early development versions. Keeping only the + # stable key avoids display names accidentally becoming program state. + for deprecated_key in ("ui_language", "locale"): + if deprecated_key in self.settings: + self.settings.pop(deprecated_key) + changed = True + + if changed: + self.settings["language"] = language + self.write_settings() + def healthy(self) -> bool: """ Checks the settings file to see if it is healthy. Returns True if all checks pass, otherwise returns False. @@ -104,16 +134,19 @@ def update_old_settings(directory: str, filename: str) -> None: if os.path.exists(old_settings): shutil.copyfile(old_settings, file) if os.path.isfile(file): - with open(file, "r") as current: + with open(file, "r", encoding="utf-8") as current: current_settings = json.load(current) if "current_bw_dir" not in current_settings: - new_settings_content = { - "current_bw_dir": current_settings["custom_bw_dir"], - "custom_bw_dirs": [current_settings["custom_bw_dir"]], - "startup_project": current_settings["startup_project"], - } - with open(file, "w") as new_file: - json.dump(new_settings_content, new_file) + new_settings_content = dict(current_settings) + custom_bw_dir = new_settings_content.pop("custom_bw_dir") + new_settings_content.update( + { + "current_bw_dir": custom_bw_dir, + "custom_bw_dirs": [custom_bw_dir], + } + ) + with open(file, "w", encoding="utf-8") as new_file: + json.dump(new_settings_content, new_file, ensure_ascii=False) @classmethod def get_default_settings(cls) -> dict: @@ -121,6 +154,7 @@ def get_default_settings(cls) -> dict: return { "current_bw_dir": cls.get_default_directory(), "custom_bw_dirs": [cls.get_default_directory()], + "language": SYSTEM_LANGUAGE, "startup_project": cls.get_default_project_name(), } @@ -151,10 +185,16 @@ def remove_custom_bw_dir(self, directory: str) -> None: try: self.settings["custom_bw_dirs"].remove(directory) self.write_settings() - except KeyError as e: + except (KeyError, ValueError) as error: QMessageBox.warning( - self, - f"Error while attempting to remove a brightway environmental dir: {e}", + None, + _("Could not remove directory"), + _( + "The Brightway data directory could not be removed from settings." + "\n\nDetails: {details}", + details=str(error), + ), + QMessageBox.Ok, ) @property @@ -197,6 +237,16 @@ def theme(self) -> str: def theme(self, new_theme: str) -> None: self.settings.update({"theme": new_theme}) + @property + def language(self) -> str: + """Return the stable interface language code.""" + + return normalize_language(self.settings.get("language", SYSTEM_LANGUAGE)) + + @language.setter + def language(self, language: str) -> None: + self.settings["language"] = normalize_language(language) + class ProjectSettings(BaseSettings): """ diff --git a/activity_browser/static/css/navigator.css b/activity_browser/static/css/navigator.css index 0d86424e6..3a6609728 100644 --- a/activity_browser/static/css/navigator.css +++ b/activity_browser/static/css/navigator.css @@ -1,5 +1,7 @@ - +body, button { + font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif; +} /*to make the svg resizable*/ /* .svg-container { @@ -192,4 +194,4 @@ div.tooltip { border-radius: 8px; pointer-events: none; /*color: red;*/ -} \ No newline at end of file +} diff --git a/activity_browser/static/css/sankey_navigator.css b/activity_browser/static/css/sankey_navigator.css index 0c70d001d..ce4482b05 100644 --- a/activity_browser/static/css/sankey_navigator.css +++ b/activity_browser/static/css/sankey_navigator.css @@ -1,5 +1,7 @@ - +body, button { + font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif; +} /*to make the svg resizable*/ /* .svg-container { @@ -226,4 +228,4 @@ div.tooltip { border-radius: 8px; pointer-events: none; /*color: red;*/ -} \ No newline at end of file +} diff --git a/activity_browser/static/javascript/navigator.js b/activity_browser/static/javascript/navigator.js index bf0208efc..b9c5ed178 100644 --- a/activity_browser/static/javascript/navigator.js +++ b/activity_browser/static/javascript/navigator.js @@ -587,8 +587,8 @@ const cartographer = function() { + '\n(' + Math.round(n['ind_norm'] * 100) + '%)'; node_data.ind_norm = n['ind_norm']; node_data.tooltip = '' + n['name'] + '' - + '
+欢迎使用 Activity Browser!+Activity Browser +是一个开源图形用户界面,旨在提高使用 +Brightway +生命周期评价(LCA)框架时的工作效率。 + |
+
LCA 结果概览 |
+ 蒙特卡洛模拟 |
+ 桑基图 |
+
|---|---|---|
![]() |
+
Parameter names must not start with a digit, hyphen, or hash character.
": "参数名称不能以数字、连字符或井号开头。
", + "Project deleted": "项目已删除", + "Project: Available to all other parameters": "项目参数:可供所有其他参数使用", + "Relink the dependencies of this database": "重新链接此数据库的依赖关系", + "Rename parameter": "重命名参数", + "Rename parameter '{name}' to:": "将参数“{name}”重命名为:", + "Select the type of parameter to create.": "选择要创建的参数类型。", + "Switch the project": "切换项目", + "Tar archive (*.tar.gz)": "Tar 压缩包 (*.tar.gz)", + "Tar archive (*.tar.gz);;All files (*.*)": "Tar 压缩包 (*.tar.gz);;所有文件 (*.*)", + "The project was successfully deleted.": "项目已成功删除。", + "The startup project cannot be deleted. Select a different startup project in Settings first.": "不能删除启动项目。请先在“设置”中选择其他启动项目。", + "Types:": "类型:" +} diff --git a/activity_browser/translations/zh_CN/core.json b/activity_browser/translations/zh_CN/core.json new file mode 100644 index 000000000..9cbb11251 --- /dev/null +++ b/activity_browser/translations/zh_CN/core.json @@ -0,0 +1,29 @@ +{ + "(Requires restart)": "(重启后生效)", + "Activity Browser Settings": "Activity Browser 设置", + "Brightway Dir: ": "Brightway 目录:", + "Browse": "浏览", + "Continue?": "继续?", + "Could not remove directory": "无法移除目录", + "Dark theme compatibility": "深色主题(兼容模式)", + "Delete Brightway2 directory?": "删除 Brightway2 目录?", + "Discrepancy in the ABsettings.json file": "ABsettings.json 文件中的设置不一致", + "English": "英语", + "Language: ": "语言:", + "Light theme": "浅色主题", + "New brightway data directory?": "新建 Brightway 数据目录?", + "Remove": "移除", + "Restore defaults": "恢复默认设置", + "Save": "保存", + "Select a brightway2 database folder": "选择 Brightway2 数据库文件夹", + "Simplified Chinese": "简体中文", + "Startup Options": "启动选项", + "Startup Project: ": "启动项目:", + "System default": "跟随系统", + "Theme: ": "主题:", + "The Brightway data directory could not be removed from settings.\n\nDetails: {details}": "无法从设置中移除 Brightway 数据目录。\n\n详细信息:{details}", + "The value provided for the current brightway directory does not exist\nin the available list of directories. Please check the settings file.": "当前 Brightway 目录的设置值不存在于\n可用目录列表中。请检查设置文件。", + "This action will remove the local information only, click'Yes' to remove\nthe projects. Data on the \"disk\" will remain untouched and needs to be removed manually": "此操作只会移除本地记录。点击“是”将移除\n这些项目;磁盘上的数据会保留,如需删除请手动处理。", + "This directory does not contain any projects. \n Would you like to setup a new brightway data directory here? \n This will close the current project and create a \"default\" project in the new directory.": "此目录不包含任何项目。\n是否在这里设置新的 Brightway 数据目录?\n这会关闭当前项目,并在新目录中创建“default”项目。", + "Would you like to switch to this directory now? \nThis will close your currently opened project. \nClick \"Yes\" to be able to choose the startup project.": "是否立即切换到此目录?\n这会关闭当前打开的项目。\n点击“是”后可选择启动项目。" +} diff --git a/activity_browser/translations/zh_CN/dialogs.json b/activity_browser/translations/zh_CN/dialogs.json new file mode 100644 index 000000000..dda15482d --- /dev/null +++ b/activity_browser/translations/zh_CN/dialogs.json @@ -0,0 +1,52 @@ +{ + "AND": "并且", + "Activity Location linking": "活动地点链接", + "Activity linking": "活动链接", + "Add a new filter for this column": "为此列添加筛选条件", + "Add filter": "添加筛选条件", + "All Files (*.*)": "所有文件 (*.*)", + "Biosphere and impact categories": "生物圈与影响类别", + "Browse": "浏览", + "Case Sensitive:": "区分大小写:", + "Choose a biosphere version": "选择生物圈版本", + "Choose how filters combine with each other.\nAND must satisfy all filters, OR must satisfy at least one filter.": "选择筛选条件的组合方式。\n“并且”要求满足全部条件,“或者”要求至少满足一个条件。", + "Choose which biosphere version\nyou would like to use": "选择您希望使用的\n生物圈版本", + "Combine columns:": "列之间的组合方式:", + "Combine filters within column:": "列内筛选条件的组合方式:", + "Confirm deletion of {project}": "确认删除项目 {project}", + "Confirm project deletion": "确认删除项目", + "Creating core data migrations for {project}": "正在为 {project} 创建核心数据迁移", + "Creating default LCIA methods for {project}": "正在为 {project} 创建默认 LCIA 方法", + "Creating default biosphere for {project}": "正在为 {project} 创建默认生物圈数据库", + "Customize database links for exchanges in the imported database.": "自定义导入数据库中交换流的数据库链接。", + "Database linking": "数据库链接", + "Database links:": "数据库链接:", + "Databases:": "数据库:", + "Define a filter for column '{column_name}'": "为列“{column_name}”定义筛选条件", + "Excel (*.xlsx);; feather (*.feather);; CSV and Archived (*.csv *.zip *.tar *.bz2 *.gz *.xz);; All Files (*.*)": "Excel (*.xlsx);; Feather (*.feather);; CSV 和压缩文件 (*.csv *.zip *.tar *.bz2 *.gz *.xz);; 所有文件 (*.*)", + "Excel sheet name": "Excel 工作表名称", + "Final confirmation to remove data from the hard disk.\nWarning: Non reversible process!": "最后确认:将从硬盘中删除数据。\n警告:此操作不可撤销!", + "If the chosen location is not found, try matching the selected locations below too": "如果找不到所选地点,也尝试匹配下方选中的地点", + "Linking scenario databases": "链接情景数据库", + "Location link:": "地点链接:", + "Manage table filters": "管理表格筛选条件", + "New name": "新名称", + "OR": "或者", + "Path to file*": "文件路径*", + "Relinking database results": "数据库重新链接结果", + "Relinking exchanges from activity '{activity}' to a new location.": "正在将活动“{activity}”的交换流重新链接到新地点。", + "Relinking exchanges from activity '{activity}'.": "正在重新链接来自活动“{activity}”的交换流。", + "Relinking exchanges from database '{database}'.": "正在重新链接来自数据库“{database}”的交换流。", + "Remove this filter": "移除此筛选条件", + "Remove {project} from the hard disk": "从硬盘中删除 {project}", + "Select file to read": "选择要读取的文件", + "Select scenario template file": "选择情景模板文件", + "Separator for csv": "CSV 分隔符", + "Some database(s) could not be found in the current project, attempt to relink the exchanges to a different database?": "当前项目中找不到部分数据库。是否尝试将交换流重新链接到其他数据库?", + "The following database(s) in the scenario file cannot be found in your project.\n\nPlease indicate the corresponding database(s), or cancel the import if this is not possible. (Warning: this process may take a few minutes for large scenario files)": "在您的项目中找不到情景文件中的以下数据库。\n\n请指定对应的数据库;如果无法指定,请取消导入。(警告:对于较大的情景文件,此过程可能需要几分钟)", + "Up to 5 unlinked exchanges (click to open)": "最多显示 5 个未链接的交换流(点击打开)", + "Use generic alternatives as fallback:": "使用通用地点作为后备选项:", + "tab": "制表符", + "{database} = {count} flows failed to link": "{database} = {count} 个流链接失败", + "{database} = {count} successfully linked": "{database} = 成功链接 {count} 个流" +} diff --git a/activity_browser/translations/zh_CN/errors.json b/activity_browser/translations/zh_CN/errors.json new file mode 100644 index 000000000..9cb7c10e9 --- /dev/null +++ b/activity_browser/translations/zh_CN/errors.json @@ -0,0 +1,5 @@ +{ + "LCA calculation failed": "LCA 计算失败", + "Scenario LCA calculation failed": "情景 LCA 计算失败", + "The constructed LCA matrix contains none of the exchanges from the scenario data.": "构建的 LCA 矩阵不包含情景数据中的任何交换流。" +} diff --git a/activity_browser/translations/zh_CN/figures.json b/activity_browser/translations/zh_CN/figures.json new file mode 100644 index 000000000..45a199b1e --- /dev/null +++ b/activity_browser/translations/zh_CN/figures.json @@ -0,0 +1,12 @@ +{ + "All files (*.*)": "所有文件 (*.*)", + "Contributions": "贡献分析", + "Figure": "图", + "LCA heatmap": "LCA 热图", + "LCA scores": "LCA 得分", + "Mean / amount": "均值/数值", + "Monte Carlo": "蒙特卡洛", + "Probability": "概率", + "Probability density": "概率密度", + "Value": "数值" +} diff --git a/activity_browser/translations/zh_CN/misc_ui.json b/activity_browser/translations/zh_CN/misc_ui.json new file mode 100644 index 000000000..19878fce3 --- /dev/null +++ b/activity_browser/translations/zh_CN/misc_ui.json @@ -0,0 +1,19 @@ +{ + "Activity Browser - a graphical interface for Brightway2.Global sensitivity analysis is a family of methods that uses generated distributions to investigate how model variables contribute to the final results.
Activity Browser performs GSA using distributions generated by a Monte Carlo simulation for the reference flows. It repeats the stochastic simulations while fixing selected variables. Comparing the lower and upper bounds then indicates how strongly each fixed variable influences the model's overall variability.
Read the detailed explanation on the wiki.
The methods are described in a published scientific paper.
": "全局敏感性分析是一类利用生成的分布来研究模型变量如何影响最终结果的方法。
Activity Browser 使用蒙特卡洛模拟为参考流生成分布,并据此执行 GSA。分析会在固定所选变量的同时重复随机模拟;比较变量的上下界,可以判断各固定变量对模型总体变异性的影响程度。
这些方法在一篇已发表的科学论文中有详细介绍。
", + "Monte Carlo simulations generate stochastic samples from the uncertainty distributions defined in the LCA model and use them to estimate the expected result distribution for each reference flow.
In practical terms, the model can define uncertainty distributions for some or all parameters. The simulation repeatedly samples from those distributions, producing a final probability distribution that describes the expected variability of the reference-flow results.
Read more about Monte Carlo simulation on the wiki.
": "蒙特卡洛模拟从 LCA 模型中定义的不确定性分布进行随机抽样,并据此估计各参考流的预期结果分布。
具体来说,可以为模型中的部分或全部参数定义不确定性分布。模拟会反复从这些分布中抽样,形成最终的概率分布,用于描述参考流结果的预期变异程度。
", + "- Elementary Flow (EF) Contributions
- Process Contributions
- First Tier (FT) Contributions
Detailed information about these approaches is available on the contribution analysis wiki page.
You can manipulate the results in several ways. See the results manipulation wiki page for details.
": "- 基本流(EF)贡献
- 过程贡献
- 第一层(FT)贡献
这些方法的详细信息见贡献分析 Wiki 页面。
可以通过多种方式处理分析结果,详情见结果处理 Wiki 页面。
", + "Absolute": "绝对值", + "Aggregate by:": "聚合方式:", + "Biosphere": "生物圈", + "Biosphere flows": "生物圈流", + "Calculation problem": "计算出现问题", + "Characterization Factors": "特征化因子", + "Choose impact category": "选择影响类别", + "Choose impact category:": "选择影响类别:", + "Choose location to save LCA results": "选择 LCA 结果的保存位置", + "Choose whether to show '0' values or not.\nWhen selected, '0' values are not shown.\nRows are only removed when all reference flows are '0'.": "选择是否显示数值为“0”的项目。\n勾选后不显示“0”值。\n只有当一行中所有参考流均为“0”时,才会移除该行。", + "Comma Separated Values (*.csv);; All Files (*.*)": "逗号分隔值 (*.csv);; 所有文件 (*.*)", + "Compare:": "比较:", + "Copy": "复制", + "Correlation Analysis": "相关性分析", + "Correlations": "相关性", + "Could not perform GSA": "无法执行全局敏感性分析", + "Could not perform Monte Carlo simulation": "无法执行蒙特卡洛模拟", + "CSV (*.csv);; All Files (*.*)": "CSV (*.csv);; 所有文件 (*.*)", + "Cut-off biosphere:": "生物圈截断值:", + "Cut-off technosphere:": "技术圈截断值:", + "EF Contributions": "基本流贡献", + "EF contributions": "基本流贡献", + "Elementary Flow Contributions": "基本流贡献", + "Excel": "Excel", + "Excel (*.xlsx);; All Files (*.*)": "Excel (*.xlsx);; 所有文件 (*.*)", + "Export all data": "导出全部数据", + "Export plot:": "导出图表:", + "Export table:": "导出表格:", + "FT Contributions": "第一层贡献", + "Filter flows:": "筛选流:", + "First Tier Contributions": "第一层贡献", + "First Tier contributions": "第一层贡献", + "Flows with categorisation factors": "有特征化因子的流", + "Flows without categorisation factors": "无特征化因子的流", + "Global Sensitivity Analysis": "全局敏感性分析", + "GSA output": "GSA 输出", + "Impact Category:": "影响类别:", + "Include all reference flows, impact categories and scenarios": "包括全部参考流、影响类别和情景", + "Include uncertainty for:": "包括以下不确定性:", + "Inventory": "清单", + "Invert": "转置", + "Iterations:": "迭代次数:", + "LCA Results": "LCA 结果", + "LCA scores": "LCA 得分", + "LCIA results": "LCIA 结果", + "Left click for help on Contribution Analysis Functions": "单击查看贡献分析功能帮助", + "Left click for help on Global Sensitivity Analysis": "单击查看全局敏感性分析帮助", + "Left click for help on Monte Carlo analysis": "单击查看蒙特卡洛分析帮助", + "Left click on the question mark for help": "单击问号查看帮助", + "Monte Carlo": "蒙特卡洛", + "Monte Carlo results": "蒙特卡洛结果", + "Monte Carlo Simulation": "蒙特卡洛模拟", + "No filtering with categorisation factors": "不按特征化因子筛选", + "Overview": "概览", + "Parameters": "参数", + "Plot": "图表", + "Process Contributions": "过程贡献", + "Process contributions": "过程贡献", + "Random seed:": "随机种子:", + "Range": "范围", + "Reference Flow:": "参考流:", + "Relative": "相对值", + "Remove '0' values": "移除“0”值", + "Rest (+)": "其余(正)", + "Rest (-)": "其余(负)", + "Run": "运行", + "Sankey": "桑基图", + "Save input/output data to Excel after run": "运行后将输入/输出数据保存到 Excel", + "Scenario:": "情景:", + "Score": "得分", + "Score Marker": "得分标记", + "Seed value (integer) for the random number generator. Use this for reproducible samples.": "随机数生成器的种子值(整数);使用相同种子可复现实验样本。", + "Seed value must be an integer number or left empty.": "随机种子必须为整数,也可以留空。", + "Sensitivity Analysis": "敏感性分析", + "Show a matrix of all reference flows and all impact categories": "显示所有参考流与所有影响类别构成的矩阵", + "Show absolute values (compare magnitudes of each contribution)": "显示绝对值(比较各项贡献的大小)", + "Show relative values (compare fraction of each contribution)": "显示相对值(比较各项贡献的占比)", + "Show the contribution relative to the total range of results.\ne.g. total negative results is -2 and total positive results is 10, then range is 12 (-2 * -1 + 10)": "显示相对于结果总范围的贡献。\n例如负结果合计为 -2,正结果合计为 10,则范围为 12(-2 × -1 + 10)。", + "Show the contributions relative to the total impact score.\ne.g. total negative results is -2 and total positive results is 10, then score is 8 (-2 + 10)": "显示相对于总影响得分的贡献。\n例如负结果合计为 -2,正结果合计为 10,则得分为 8(-2 + 10)。", + "Show the impacts of each reference flow for the selected impact categories": "显示各参考流在所选影响类别下的影响", + "Shows the score marker. When there are both positive and negative results,\nthis shows a marker where the total score is.": "显示得分标记。当结果同时包含正值和负值时,\n该标记表示总得分的位置。", + "Table": "表格", + "Technosphere": "技术圈", + "Technosphere flows": "技术圈流", + "Technosphere inventory": "技术圈清单", + "TSV (*.tsv);; All Files (*.*)": "TSV (*.tsv);; 所有文件 (*.*)", + "Warning": "警告", + "You need to run a Monte Carlo Simulation first.": "需要先运行蒙特卡洛模拟。", + "by impact category": "按影响类别", + "categories": "类别", + "database": "数据库", + "location": "地点", + "name": "名称", + "none": "不聚合", + "reference product": "参考产品", + "relative share": "相对占比", + "type": "类型", + "unit": "单位", + "units of each impact category": "各影响类别的单位", + "{name}[Scenarios]": "{name}[情景]" +} diff --git a/activity_browser/translations/zh_CN/semantics.json b/activity_browser/translations/zh_CN/semantics.json new file mode 100644 index 000000000..e12aecbd5 --- /dev/null +++ b/activity_browser/translations/zh_CN/semantics.json @@ -0,0 +1,145 @@ +{ + " and ": " 和 ", + "&About Activity Browser": "关于 Activity Browser(&A)", + "&About Qt": "关于 Qt(&Q)", + "&Activity History": "活动历史(&H)", + "&Get help on the wiki": "在 Wiki 中获取帮助(&G)", + "&Graph Explorer": "图形浏览器(&G)", + "&Help": "帮助(&H)", + "&Project": "项目(&P)", + "&Report an idea/issue on GitHub": "在 GitHub 上提交建议或问题(&R)", + "&Tools": "工具(&T)", + "&View": "视图(&V)", + "&Welcome screen": "欢迎页(&W)", + "AND": "并且", + "About the Activity Browser": "关于 Activity Browser", + "Absolute": "绝对值", + "Active column filters:": "当前列筛选条件:", + "Activity Details": "活动详情", + "Add a new filter for this column": "为此列添加筛选条件", + "Add filter": "添加筛选条件", + "Aggregate by:": "聚合方式:", + "Biosphere": "生物圈", + "Biosphere flows": "生物圈流", + "Case Sensitive:": "区分大小写:", + "Characterization Factors": "特征化因子", + "Choose how filters combine with each other.\nAND must satisfy all filters, OR must satisfy at least one filter.": "选择筛选条件的组合方式。\n“并且”要求满足全部条件,“或者”要求至少满足一个条件。", + "Choose impact category": "选择影响类别", + "Choose impact category:": "选择影响类别:", + "Choose whether to show '0' values or not.\nWhen selected, '0' values are not shown.\nRows are only removed when all reference flows are '0'.": "选择是否显示数值为“0”的项目。\n勾选后不显示“0”值。\n只有当一行中所有参考流均为“0”时,才会移除该行。", + "Combine columns:": "列之间的组合方式:", + "Combine filters within column:": "列内筛选条件的组合方式:", + "Compare:": "比较:", + "Copy": "复制", + "Correlation Analysis": "相关性分析", + "Cut-off biosphere:": "生物圈截断值:", + "Cut-off technosphere:": "技术圈截断值:", + "Define a filter for column '{column_name}'": "为列“{column_name}”定义筛选条件", + "EF Contributions": "基本流贡献", + "Elementary Flow Contributions": "基本流贡献", + "Excel": "Excel", + "Export all data": "导出全部数据", + "Export plot:": "导出图表:", + "Export table:": "导出表格:", + "FT Contributions": "第一层贡献", + "Filter flows:": "筛选流:", + "Filter this column on the input,\npress 'enter' or the search button to filter": "按输入内容筛选此列,\n按 Enter 键或搜索按钮执行筛选", + "First Tier Contributions": "第一层贡献", + "Flows with categorisation factors": "有特征化因子的流", + "Flows without categorisation factors": "无特征化因子的流", + "Global Sensitivity Analysis": "全局敏感性分析", + "Graph Explorer": "图形浏览器", + "History": "历史", + "Impact Categories": "影响类别", + "Impact Category:": "影响类别:", + "Include all reference flows, impact categories and scenarios": "包括全部参考流、影响类别和情景", + "Include uncertainty for:": "包括以下不确定性:", + "Inventory": "清单", + "Invert": "转置", + "Iterations:": "迭代次数:", + "LCA Results": "LCA 结果", + "LCA Setup": "LCA 设置", + "LCA results": "LCA 结果", + "Left click for help on Contribution Analysis Functions": "单击查看贡献分析功能帮助", + "Left click for help on Global Sensitivity Analysis": "单击查看全局敏感性分析帮助", + "Left click for help on Monte Carlo analysis": "单击查看蒙特卡洛分析帮助", + "Left click on the question mark for help": "单击问号查看帮助", + "Manage filters": "管理筛选条件", + "Manage table filters": "管理表格筛选条件", + "Migrations": "数据迁移", + "Monte Carlo": "蒙特卡洛", + "Monte Carlo Simulation": "蒙特卡洛模拟", + "More filters": "更多筛选条件", + "No filtering with categorisation factors": "不按特征化因子筛选", + "OR": "或者", + "Open project": "打开项目", + "Open the filter management menu": "打开筛选条件管理菜单", + "Overview": "概览", + "Parameters": "参数", + "Plot": "图表", + "Process Contributions": "过程贡献", + "Project": "项目", + "Quick filter ...": "快速筛选...", + "Random seed:": "随机种子:", + "Range": "范围", + "Reference Flow:": "参考流:", + "Reference Flows": "参考流", + "Relative": "相对值", + "Remove '0' values": "移除“0”值", + "Remove all filters": "移除全部筛选条件", + "Remove all filters in this table": "移除此表中的全部筛选条件", + "Remove all filters on this column": "移除此列的全部筛选条件", + "Remove column filters": "移除列筛选条件", + "Remove this filter": "移除此筛选条件", + "Run": "运行", + "Sankey": "桑基图", + "Save input/output data to Excel after run": "运行后将输入/输出数据保存到 Excel", + "Scenario:": "情景:", + "Scenarios": "情景", + "Score": "得分", + "Score Marker": "得分标记", + "Seed value (integer) for the random number generator. Use this for reproducible samples.": "随机数生成器的种子值(整数);使用相同种子可复现实验样本。", + "Seed value must be an integer number or left empty.": "随机种子必须为整数,也可以留空。", + "Sensitivity Analysis": "敏感性分析", + "Show a matrix of all reference flows and all impact categories": "显示所有参考流与所有影响类别构成的矩阵", + "Show absolute values (compare magnitudes of each contribution)": "显示绝对值(比较各项贡献的大小)", + "Show relative values (compare fraction of each contribution)": "显示相对值(比较各项贡献的占比)", + "Show the contribution relative to the total range of results.\ne.g. total negative results is -2 and total positive results is 10, then range is 12 (-2 * -1 + 10)": "显示相对于结果总范围的贡献。\n例如负结果合计为 -2,正结果合计为 10,则范围为 12(-2 × -1 + 10)。", + "Show the contributions relative to the total impact score.\ne.g. total negative results is -2 and total positive results is 10, then score is 8 (-2 + 10)": "显示相对于总影响得分的贡献。\n例如负结果合计为 -2,正结果合计为 10,则得分为 8(-2 + 10)。", + "Show the impacts of each reference flow for the selected impact categories": "显示各参考流在所选影响类别下的影响", + "Shows the score marker. When there are both positive and negative results,\nthis shows a marker where the total score is.": "显示得分标记。当结果同时包含正值和负值时,\n该标记表示总得分的位置。", + "Table": "表格", + "Technosphere": "技术圈", + "Technosphere flows": "技术圈流", + "Warning": "警告", + "Welcome": "欢迎", + "You need to run a Monte Carlo Simulation first.": "需要先运行蒙特卡洛模拟。", + "by impact category": "按影响类别", + "categories": "类别", + "contains": "包含", + "database": "数据库", + "does not contain": "不包含", + "does not end with": "不以指定内容结尾", + "does not equal": "不等于", + "does not start with": "不以指定内容开头", + "ends with": "结尾为", + "equals": "等于", + "location": "地点", + "name": "名称", + "none": "不聚合", + "reference product": "参考产品", + "starts with": "开头为", + "type": "类型", + "unit": "单位", + "values in the column are between": "列中的值介于指定范围", + "values in the column are greater than or equal to": "列中的值大于或等于指定值", + "values in the column are smaller than or equal to": "列中的值小于或等于指定值", + "values in the column contain": "列中的值包含指定内容", + "values in the column do not contain": "列中的值不包含指定内容", + "values in the column do not end with": "列中的值不以指定内容结尾", + "values in the column do not equal": "列中的值不等于指定内容", + "values in the column do not start with": "列中的值不以指定内容开头", + "values in the column end with": "列中的值以指定内容结尾", + "values in the column equal": "列中的值等于指定内容", + "values in the column start with": "列中的值以指定内容开头" +} diff --git a/activity_browser/translations/zh_CN/shell.json b/activity_browser/translations/zh_CN/shell.json new file mode 100644 index 000000000..e0ed92ed8 --- /dev/null +++ b/activity_browser/translations/zh_CN/shell.json @@ -0,0 +1,14 @@ +{ + "&Main Window": "主窗口(&M)", + "Confirm action": "确认操作", + "Database": "数据库", + "Database: None": "数据库:无", + "Database: {database}": "数据库:{database}", + "Explanation": "说明", + "Information": "信息", + "No explanation is available for this page yet.": "此页面暂时没有可用说明。", + "No method selected yet": "尚未选择方法", + "Project": "项目", + "Project: {project}": "项目:{project}", + "Welcome": "欢迎" +} diff --git a/activity_browser/translations/zh_CN/superstructure.json b/activity_browser/translations/zh_CN/superstructure.json new file mode 100644 index 000000000..b574728ae --- /dev/null +++ b/activity_browser/translations/zh_CN/superstructure.json @@ -0,0 +1,33 @@ +{ + "Check the scenario columns and exchange values in the file before loading it again.
": "请检查文件中的情景列和交换流数值,然后重新加载。
", + "Duplicates have been found, meaning that there are several rows in the scenario file describing scenarios for the same flow. The AB can deal with this by discarding all but the last row for this exchange.
Press 'Ok' to proceed, press 'Cancel' to abort.
": "发现重复项,即情景文件中有多行描述同一交换流。Activity Browser 可以丢弃该交换流除最后一行外的其他行。
按“确定”继续,按“取消”中止。
", + "No exchange values were found in the last loaded scenario file. Exchange values must be recorded in a named scenario column distinct from the required default columns shown below:
": "最近加载的情景文件中没有找到交换流数值。交换流数值必须记录在有名称的情景列中,并与下列必需的默认列区分开:
", + "Non-numeric data is present in the scenario exchange columns.
The Activity-Browser can only deal with numeric data for the calculations. To resolve this corrections will need to be made to these values in the scenario file.
": "情景交换流列中包含非数值数据。
Activity Browser 只能使用数值进行计算。请在情景文件中修正这些值。
", + "One, or several, exchanges (rows) in the scenario file could not be found in the database (meaning: a part or all of the exchange information, i.e. input or output product/activity/unit/geography, or the key, have no match in the project databases).
It is not possible to proceed at this point. you may save the scenario file with an additional column indicating the problematic exchanges.
": "情景文件中的一个或多个交换流(行)在数据库中找不到匹配项。这表示部分或全部交换流信息(输入或输出的产品、活动、单位、地理位置或键)与项目数据库不匹配。
目前无法继续。可以保存情景文件,并用新增列标记存在问题的交换流。
", + "While importing the scenario difference files one, or more, of the scenarios could not be found between the files.
In these circumstances the Activity-Browser will only retain those scenarios found in common between these files. If some desired scenarios are not included, then please inspect your scenario files for the relevant columns.": "导入情景差异文件时,发现一个或多个情景并非所有文件都包含。
Activity Browser 将只保留这些文件共有的情景。如果缺少所需情景,请检查各情景文件的相关列。", + "Activities not found": "未找到活动", + "Activity not found": "未找到活动", + "All reference flows must be non-zero.": "所有参考流都必须为非零值。", + "An activity could not be relinked to the local database.A parameter scenario file requires these headers:
": "
参数情景文件需要包含以下表头:
",
+ "〈filename〉": "〈文件名〉",
+ "
Create and modify project, database, and activity parameters here. A parameter must have a name and amount; a formula and uncertainty are optional.
Project parameters are available throughout the project, while database and activity parameters have narrower scopes. Parameters used by another formula cannot be deleted.
Create activity parameters by dragging an activity from the database table or by adding a formula to an exchange. Only editable databases can be parameterized.
": "在这里创建和修改项目、数据库及活动参数。参数必须有名称和数值,公式和不确定性为可选项。
项目参数可在整个项目中使用,数据库参数和活动参数的作用域较小。已被其他公式引用的参数不能删除。
可从数据库表拖入活动,或为交换流添加公式来创建活动参数。只有可编辑数据库中的活动才能参数化。
", + "Export parameter scenarios to Excel, edit them, and import them here for inspection. To use the file in a scenario LCA, import it in Calculation Setup.
Export as flow scenarios converts alternative parameter values into alternative exchange values used in LCA calculations.
": "将参数情景导出到 Excel 后可进行编辑,再导入此处查看。若要在情景 LCA 中使用该文件,请在“计算设置”中导入。
“导出为流情景”会把参数的备选值转换成 LCA 计算所用交换流的备选值。
", + "This tab lists exchanges in the selected project whose values are calculated from parameters.
Project parameters appear above database and activity parameters. Expand the tree to inspect their dependent exchanges.
": "此标签页列出所选项目中由参数计算数值的交换流。
项目参数显示在数据库参数和活动参数上方。展开树结构可查看它们所影响的交换流。
", + "You can import two types of scenario files:
1. Flow scenarios: alternative exchange values for technosphere or biosphere flows (scenario difference files).
2. Parameter scenarios: alternative parameter values.
Templates can be exported from Parameters > Scenarios. See the Activity Browser wiki for details.
With multiple files, Combine scenarios creates every combination; Extend scenarios joins values for identically named scenarios across files.
": "这里可导入两类情景文件:
1. 流情景:技术圈或生物圈交换流的备选值(情景差异文件)。
2. 参数情景:参数的备选值。
可在参数 > 情景中导出模板,详细说明见 Activity Browser wiki。
使用多个文件时,组合情景会生成所有组合;扩展情景会连接各文件中名称相同的情景值。
", + "Activity Browser expected a scenario file, but detected another file type or an invalid format.A flow scenario file requires these headers:
": "Activity Browser 预期导入情景文件,但检测到其他文件类型或无效格式。
流情景文件需要包含以下表头:
",
+ "Activity parameters": "活动参数",
+ "Activity selection history:": "活动选择历史:",
+ "Activity:": "活动:",
+ "Add scenarios": "添加情景",
+ "Biosphere flows:": "生物圈流:",
+ "Calculate": "计算",
+ "Calculation setup:": "计算设置:",
+ "Cannot load parameters": "无法加载参数",
+ "Choose where to save the scenario file": "选择情景文件保存位置",
+ "Clear the search": "清除搜索",
+ "Click the question mark for help.": "单击问号查看帮助。",
+ "Click to enable editing. Changes are saved automatically.": "单击以启用编辑。更改会自动保存。",
+ "Click to prevent further editing. Changes are saved automatically.": "单击以停止继续编辑。更改会自动保存。",
+ "Combine scenarios": "组合情景",
+ "Comments": "备注",
+ "Convert the current parameter scenario table into prepared flow scenario data.": "将当前参数情景表转换为可用的流情景数据。",
+ "Create a calculation setup with New before running an LCA.": "运行 LCA 前,请先单击“新建”创建计算设置。",
+ "Database parameters": "数据库参数",
+ "Database:": "数据库:",
+ "Databases:": "数据库:",
+ "Default column not found": "未找到默认列",
+ "Definitions": "定义",
+ "Delete": "删除",
+ "Description": "说明",
+ "Double-click an entry to select a database.": "双击条目以选择数据库。",
+ "Downstream consumers:": "下游使用者:",
+ "Drag impact categories or groups into the calculation setup.": "将影响类别或类别组拖入计算设置。",
+ "Edit activity": "编辑活动",
+ "Edit characterization factors": "编辑特征化因子",
+ "Exchanges": "交换流",
+ "Expand the tree to inspect their dependent exchanges.": "展开树结构可查看它们所影响的交换流。",
+ "Export as flow scenarios": "导出为流情景",
+ "Export parameter scenarios": "导出参数情景",
+ "Export the current parameter scenario table to Excel.": "将当前参数情景表导出到 Excel。",
+ "Extend scenarios": "扩展情景",
+ "File save error": "文件保存错误",
+ "Filter activities": "筛选活动",
+ "Help for Brightway parameters": "Brightway 参数帮助",
+ "Help for parameter scenarios": "参数情景帮助",
+ "Help for parameters": "参数帮助",
+ "Help for scenarios": "情景帮助",
+ "Hide uncertainty columns": "隐藏不确定性列",
+ "If many matches are found, the tree is not expanded automatically.": "若匹配项很多,树结构不会自动展开。",
+ "Impact categories": "影响类别",
+ "Impact categories:": "影响类别:",
+ "Import parameter scenarios": "导入参数情景",
+ "List view": "列表视图",
+ "List view of impact categories": "影响类别列表视图",
+ "List view of the database": "数据库列表视图",
+ "Load": "加载",
+ "Load an Excel file containing additional parameter scenarios.": "加载包含其他参数情景的 Excel 文件。",
+ "Load new data into this scenario table.": "向此情景表加载新数据。",
+ "Make this impact category editable.\nDuplicate it before modifying its characterization factors.": "使此影响类别可编辑。\n修改特征化因子前,请先复制该影响类别。",
+ "Method: {method}": "方法:{method}",
+ "More information:": "更多信息:",
+ "Overview of parameterized exchanges": "参数化交换流概览",
+ "Parameter scenarios": "参数情景",
+ "Parameters": "参数",
+ "Products:": "产品:",
+ "Project:": "项目:",
+ "Reference flows:": "参考流:",
+ "Remove this scenario table.": "移除此情景表。",
+ "Reset table": "重置表格",
+ "Reset the scenario table and discard changes.": "重置情景表并放弃更改。",
+ "Save calculated flow scenarios to Excel": "将计算得到的流情景保存到 Excel",
+ "Save current scenarios to Excel": "将当前情景保存到 Excel",
+ "Save to file...": "保存到文件...",
+ "Scenario LCA": "情景 LCA",
+ "Scenarios": "情景",
+ "Scenarios:": "情景:",
+ "Search": "搜索",
+ "Search impact categories": "搜索影响类别",
+ "Search impact categories. If many matches are found, the tree is not expanded automatically.": "搜索影响类别。若匹配项很多,树结构不会自动展开。",
+ "Select a prepared scenario file": "选择已准备的情景文件",
+ "Show group column": "显示组列",
+ "Show in Graph Explorer": "在图形浏览器中显示",
+ "Show order column": "显示顺序列",
+ "Show or hide the activity description.": "显示或隐藏活动说明。",
+ "Show or hide the activity parameters.": "显示或隐藏活动参数。",
+ "Show or hide the comment column.": "显示或隐藏备注列。",
+ "Show or hide the database parameters.": "显示或隐藏数据库参数。",
+ "Show or hide the uncertainty columns.": "显示或隐藏不确定性列。",
+ "Standard LCA": "标准 LCA",
+ "Technosphere flows:": "技术圈流:",
+ "The database containing this activity is read-only. Use the checkbox in the database list to enable editing.": "此活动所在的数据库为只读。请使用数据库列表中的复选框启用编辑。",
+ "The file could not be saved. It may be open elsewhere, or you may not have permission to save in that location.\n\n{error}": "无法保存文件。文件可能已在其他程序中打开,或您没有在该位置保存的权限。\n\n{error}",
+ "The scenario file contains more parameter rows than the current project.": "情景文件中的参数行数多于当前项目,无法加载。",
+ "This table is read-only. Use the import and export functions.": "此表为只读。请使用导入和导出功能。",
+ "Total scenarios: {scenario_count} | Variable flows: {flow_count}": "情景总数:{scenario_count} | 可变流总数:{flow_count}",
+ "Tree view": "树视图",
+ "Tree view of impact categories\nv CML 2001\n v climate change\n CML 2001, climate change, GWP 100a\n ...\nYou can drag entire branches of impact categories at once.": "影响类别树视图\nv CML 2001\n v climate change\n CML 2001, climate change, GWP 100a\n ...\n可一次拖动影响类别的整个分支。",
+ "Tree view of the database": "数据库树视图",
+ "Try to load and include the 'default' scenario column?": "是否尝试加载并包含“default”情景列?",
+ "Uncertainty": "不确定性",
+ "Use the checkbox in the table to switch a database between read-only and editable.": "使用表格中的复选框在只读和可编辑状态之间切换数据库。",
+ "Wrong file type": "文件类型错误"
+}
diff --git a/activity_browser/translations/zh_CN/web.json b/activity_browser/translations/zh_CN/web.json
new file mode 100644
index 000000000..c8d0cbbfb
--- /dev/null
+++ b/activity_browser/translations/zh_CN/web.json
@@ -0,0 +1,58 @@
+{
+ "{amount:.3g} {unit} of {product}": "{product}:{amount:.3g} {unit}",
+ "\"Add only direct up-/downstream exchanges\" offers two expansion options:": "“仅添加直接上游/下游交换流”提供两种展开方式:",
+ "\"Flip negative flows\" (experimental) reverses arrows for negative product flows, such as treatment or substitution flows. This can make physical product flows easier to understand.": "“反转负流”(实验性功能)会反转负产品流(例如处理或替代流)的箭头方向,以便更直观地理解实际产品流向。",
+ "\"Remove orphaned nodes\" removes nodes that no longer connect to the central activity after a node is deleted. Clear the checkbox to keep them.": "“移除孤立节点”会在删除节点后,移除不再与中心活动相连的节点;取消勾选可保留这些节点。",
+ "- alt + click: remove the activity": "- Alt + 单击:移除活动",
+ "- click: expand upstream": "- 单击:向上游展开",
+ "- shift + click: expand downstream": "- Shift + 单击:向下游展开",
+ "1) Add only the selected direct upstream/downstream nodes and connections (default).": "1)仅添加所选的直接上游/下游节点和连接(默认)。",
+ "2) Also add every other connection between activities already in the graph.": "2)同时添加图中已有活动之间的所有其他连接。",
+ "Add only direct up-/downstream exchanges": "仅添加直接上游/下游交换流",
+ "All Files (*.*)": "所有文件 (*.*)",
+ "Back": "后退",
+ "Calculate": "计算",
+ "Calculation depth: ": "计算深度:",
+ "Choose location to save SVG": "选择 SVG 保存位置",
+ "Click an activity to expand the graph.": "单击活动以展开图形。",
+ "Click an activity to make it the new central activity instead of expanding the graph.": "单击活动可将其设为新的中心活动,而不是展开图形。",
+ "Cumulative impact": "累积影响",
+ "Current mode: Expansion": "当前模式:展开",
+ "Current mode: Navigation": "当前模式:导航",
+ "Cutoff: ": "截断阈值:",
+ "Download SVG": "下载 SVG",
+ "EXPANSION MODE (DEFAULT):": "展开模式(默认):",
+ "Flip negative flows": "反转负流",
+ "Flip negative product flows (e.g. from ecoinvent treatment activities or from substitution)": "反转负产品流(例如来自 ecoinvent 处理活动或替代流)",
+ "Forward": "前进",
+ "Going back.": "正在后退。",
+ "Going forward.": "正在前进。",
+ "Graph": "图形",
+ "Graph LCA": "LCA 图形",
+ "Graph Navigator": "图形浏览器",
+ "Graph SVG Export": "图形 SVG 导出",
+ "Green flows: Avoided impacts": "绿色流:避免的影响",
+ "Help": "帮助",
+ "How to use the Graph Navigator:": "图形浏览器使用方法:",
+ "Impact indicator: ": "影响指标:",
+ "Individual impact": "单项影响",
+ "LCA Sankey:": "LCA 桑基图:",
+ "NAVIGATION MODE:": "导航模式:",
+ "No data to go back to.": "没有可后退的数据。",
+ "No data to go forward to.": "没有可前进的数据。",
+ "Not possible.": "无法执行。",
+ "Please load a database first.": "请先加载数据库。",
+ "Random Activity": "随机活动",
+ "Red flows: Impacts": "红色流:影响",
+ "Reference flow: ": "参考流:",
+ "Reference flow: {amount:.2g} {unit} {product} | {activity} | {location}
Total impact: {impact:.2g} {impact_unit}": "参考流:{amount:.2g} {unit} {product} | {activity} | {location}
总影响:{impact:.2g} {impact_unit}",
+ "Refresh HTML": "刷新图形",
+ "Reloading graph": "正在重新加载图形",
+ "Remove orphaned nodes": "移除孤立节点",
+ "Reset Zoom": "重置缩放",
+ "Scenario: ": "情景:",
+ "The first option produces a cleaner, but incomplete, graph.": "第一种方式生成的图形更清晰,但并不完整。",
+ "This help text describes how to use the graph.": "此帮助文字介绍图形的使用方法。",
+ "When adding activities, show product flows between ALL activities or just selected up-/downstream flows": "添加活动时,显示所有活动之间的产品流,或仅显示所选的上游/下游流",
+ "When removing activities, automatically remove those that have no further connection to the original product": "移除活动时,自动移除与原始产品不再相连的活动"
+}
diff --git a/activity_browser/translations/zh_CN/widgets.json b/activity_browser/translations/zh_CN/widgets.json
new file mode 100644
index 000000000..fad7617d1
--- /dev/null
+++ b/activity_browser/translations/zh_CN/widgets.json
@@ -0,0 +1,29 @@
+{
+ "Activity database not found": "未找到活动所属数据库",
+ "Adding new flows to the biosphere database": "正在向生物圈数据库添加新流",
+ "An error occurred while saving parameters.\nDiscard changes or cancel and continue editing?": "保存参数时发生错误。\n要放弃更改,还是取消并继续编辑?",
+ "Cannot save parameters": "无法保存参数",
+ "Cumulative %": "累计百分比",
+ "Cut-off level": "截断水平",
+ "Cut-off type": "截断类型",
+ "Database": "数据库",
+ "Enter the cut-off level.": "输入截断水平。",
+ "Location": "地点",
+ "Minimum %": "最小百分比",
+ "Move the cut-off value by one increment.": "将截断值移动一个步长。",
+ "Name": "名称",
+ "Select a different database to copy this activity to.": "选择要将此活动复制到的其他数据库。",
+ "Select an existing location from the current activity database, or add a new location.": "从当前活动数据库中选择已有地点,或添加新地点。",
+ "Set the cumulative contribution percentage to show.": "设置要显示的累计贡献百分比。",
+ "Set the minimum contribution percentage to show.": "设置要显示的最小贡献百分比。",
+ "Set the number of largest contributors to show.": "设置要显示的最大贡献者数量。",
+ "Show a given number of the largest contributors (for example, the top 5 contributors).": "显示指定数量的最大贡献者(例如贡献最大的 5 项)。",
+ "Show contributions that are at least a given percentage (for example, contributions of at least 5% of the total impact).": "显示至少达到指定百分比的贡献(例如占总影响至少 5% 的贡献)。",
+ "Show the largest contributions whose cumulative share reaches a given percentage (for example, contributors that together account for 80% of the total impact).": "显示累计占比达到指定百分比的最大贡献项(例如合计占总影响 80% 的贡献者)。",
+ "Top #": "前若干项",
+ "Updating '{database}' database": "正在更新“{database}”数据库",
+ "Use the drop-down menu to copy this activity to another database.": "使用下拉菜单将此活动复制到其他数据库。",
+ "cumulative %": "累计百分比",
+ "minimum %": "最小百分比",
+ "number": "数量"
+}
diff --git a/activity_browser/translations/zh_CN/wizard_import.json b/activity_browser/translations/zh_CN/wizard_import.json
new file mode 100644
index 000000000..64ffc1c72
--- /dev/null
+++ b/activity_browser/translations/zh_CN/wizard_import.json
@@ -0,0 +1,83 @@
+{
+ "\n\nIf you work offline you can use your previously downloaded databases via the archive option of the import wizard.": "\n\n如果您处于离线状态,可以通过导入向导的压缩包选项使用之前下载的数据库。",
+ "Finished!": "已完成!",
+ "An unexpected error occurred, please try again status code {status_code}": "发生意外错误,请重试。状态码:{status_code}",
+ "Applying brightway2 strategies:": "正在应用 brightway2 策略:",
+ "Browse": "浏览",
+ "Cannot find any valid data with the given login credentials": "使用给定的登录凭据找不到任何有效数据",
+ "Cannot find files": "找不到文件",
+ "Choose ecoinvent version and system model:": "选择 ecoinvent 版本和系统模型:",
+ "Choose location of 7z archive:": "选择 7z 压缩包的位置:",
+ "Choose location of existing ecospold2 directory:": "选择现有 ecospold2 目录的位置:",
+ "Connection Problem": "连接问题",
+ "Corrupted (.7z) archive": "损坏的 (.7z) 压缩包",
+ "Current Project: {project}": "当前项目:{project}",
+ "Data source:": "数据来源:",
+ "Database {database} already exists in project {project}!": "项目 {project} 中已存在数据库 {database}!",
+ "Database Import Wizard": "数据库导入向导",
+ "Database exists!": "数据库已存在!",
+ "Decompressing the 7z archive:": "正在解压 7z 压缩包:",
+ "Download forwast from {link}": "从 {link} 下载 forwast",
+ "Downloading data from ecoinvent homepage:": "正在从 ecoinvent 网站下载数据:",
+ "Ecoinvent version: {version}
Ecoinvent system model: {system_model}
Dependent Database: {database}": "ecoinvent 版本:{version}
ecoinvent 系统模型:{system_model}
依赖数据库:{database}",
+ "Excel (*.xlsx);; All Files (*.*)": "Excel (*.xlsx);; 所有文件 (*.*)",
+ "Excel data contains exchanges that could not be linked.": "Excel 数据中包含无法链接的交换流。",
+ "Expecting 'local' import database file to have '.bw2package' extension": "本地导入的数据库文件应使用“.bw2package”扩展名",
+ "Expecting excel file to have '.xls' or '.xlsx' extension": "Excel 文件应使用“.xls”或“.xlsx”扩展名",
+ "Extracting XML data from ecospold files:": "正在从 ecospold 文件提取 XML 数据:",
+ "File not found!": "找不到文件!",
+ "File not found:
{path}": "找不到文件:
{path}",
+ "Finalizing:": "正在完成:",
+ "Import Database": "导入数据库",
+ "Import Summary:": "导入摘要:",
+ "Import excel database file:": "导入 Excel 数据库文件:",
+ "Import local data": "导入本地数据",
+ "Import local database file:": "导入本地数据库文件:",
+ "Import remote data (download)": "导入远程数据(下载)",
+ "Invalid extension": "文件扩展名无效",
+ "Invalid username and/or password, please try again.": "用户名和/或密码无效,请重试。",
+ "Local 7z-archive of ecospold2 files": "包含 ecospold2 文件的本地 7z 压缩包",
+ "Local Excel file": "本地 Excel 文件",
+ "Local brightway database file": "本地 Brightway 数据库文件",
+ "Local directory with ecospold2 files": "包含 ecospold2 文件的本地目录",
+ "Login": "登录",
+ "Login with your ecoinvent credentials to authorize the download": "使用您的 ecoinvent 凭据登录,以授权下载",
+ "Missing databases": "缺少数据库",
+ "Missing exchanges": "缺少交换流",
+ "Name of the new database:": "新数据库的名称:",
+ "Name of the new database: {database}": "新数据库名称:{database}",
+ "No ecospold files found in this directory:
{path}": "此目录中未找到 ecospold 文件:
{path}",
+ "No ecospold files!": "没有 ecospold 文件!",
+ "Not a 7zip archive!": "不是 7zip 压缩包!",
+ "Not a directory!": "不是目录!",
+ "Not a directory:
{path}": "不是目录:
{path}",
+ "Package data links to database names that do not exist: {databases}": "软件包中的数据链接到了不存在的数据库名称:{databases}",
+ "Path to 7z archive:
{path}": "7z 压缩包路径:
{path}",
+ "Path to directory with ecospold files:
{path}": "包含 ecospold 文件的目录路径:
{path}",
+ "Path to file*": "文件路径*",
+ "Path to local file:
{path}": "本地文件路径:
{path}",
+ "Previous downloads:": "以前下载的文件:",
+ "Relinking failed": "重新链接失败",
+ "Select 7z archive": "选择 7z 压缩包",
+ "Select a valid BW2Package file": "选择有效的 BW2Package 文件",
+ "Select an excel database file": "选择 Excel 数据库文件",
+ "Select directory with ecospold2 files": "选择包含 ecospold2 文件的目录",
+ "Some exchanges could not be linked in databases: '[{databases}]'": "部分交换流无法链接到这些数据库:‘[{databases}]’",
+ "System model: ": "系统模型:",
+ "The archive '{path}' is corrupted, please remove and re-download it.": "压缩包“{path}”已损坏,请将其删除并重新下载。",
+ "The import failed because the biosphere3 database of this project is incompatible with the version of ecoinvent that you're trying to install": "导入失败,因为此项目的 biosphere3 数据库与您尝试安装的 ecoinvent 版本不兼容",
+ "The import has failed, likely due missing exchanges.": "导入失败,可能是因为缺少交换流。",
+ "The request timed out, please check your internet connection!": "请求超时,请检查网络连接!",
+ "Type of data import:": "数据导入类型:",
+ "Unexpected Problem": "意外问题",
+ "Unexpected error": "意外错误",
+ "Unexpected filetype: {suffix}
Import might not work. Continue anyway?": "文件类型不符合预期:{suffix}
导入可能无法正常进行。仍要继续吗?",
+ "Unknown connection error, try again later.": "未知连接错误,请稍后重试。",
+ "Unknown object": "未知对象",
+ "Unlinked exchanges": "未链接的交换流",
+ "Version: ": "版本:",
+ "Writing datasets to SQLite database:": "正在将数据集写入 SQLite 数据库:",
+ "ecoinvent (requires login)": "ecoinvent(需要登录)",
+ "ecoinvent password": "ecoinvent 密码",
+ "ecoinvent username": "ecoinvent 用户名"
+}
diff --git a/activity_browser/translations/zh_CN/wizards_misc.json b/activity_browser/translations/zh_CN/wizards_misc.json
new file mode 100644
index 000000000..890d01411
--- /dev/null
+++ b/activity_browser/translations/zh_CN/wizards_misc.json
@@ -0,0 +1,81 @@
+{
+ "1) Data from area under study": "1)来自研究区域的数据",
+ "1) Data from enterprises, processes and materials under study": "1)来自所研究企业、过程和材料的数据",
+ "1) Data less than 3 years old": "1)数据年限少于 3 年",
+ "1) Representative relevant data from all sites, over an adequate period": "1)在足够长时期内,来自所有场址的有代表性相关数据",
+ "1) Verified data based on measurements": "1)基于测量的已验证数据",
+ "2) Average data from larger area in which area under study is included": "2)来自包含研究区域的更大区域的平均数据",
+ "2) Data from processes and materials under study, different enterprise": "2)来自所研究过程和材料、但来自不同企业的数据",
+ "2) Data less than 6 years old": "2)数据年限少于 6 年",
+ "2) Representative relevant data from >50% sites, over an adequate period": "2)在足够长时期内,来自超过 50% 场址的有代表性相关数据",
+ "2) Verified data partly based on assumptions": "2)部分基于假设的已验证数据",
+ "3) Data from area with similar production conditions": "3)来自生产条件相似区域的数据",
+ "3) Data from processes and materials under study from different technology": "3)来自所研究过程和材料、但采用不同技术的数据",
+ "3) Data less than 10 years old": "3)数据年限少于 10 年",
+ "3) Non-verified data partly based on qualified estimates": "3)未经验证的数据,部分基于有依据的估算",
+ "3) Representative relevant data from <50% sites OR >50%, but over shorter period": "3)来自少于 50% 场址,或来自超过 50% 场址但时间较短的有代表性相关数据",
+ "4) Data from area with slightly similar production conditions": "4)来自生产条件略微相似区域的数据",
+ "4) Data less than 15 years old": "4)数据年限少于 15 年",
+ "4) Data on related processes and materials": "4)相关过程和材料的数据",
+ "4) Qualified estimate": "4)有依据的估算(如行业专家估算)",
+ "4) Representative relevant data from one site OR some sites but over shorter period": "4)来自单个场址,或来自若干场址但时间较短的有代表性相关数据",
+ "5) Data age unknown or more than 15 years old": "5)数据年限未知或超过 15 年",
+ "5) Data from unknown OR distinctly different area": "5)来自未知区域或明显不同区域的数据",
+ "5) Data on related processes on lab scale OR from different technology": "5)实验室规模相关过程的数据,或采用不同技术的数据",
+ "5) Non-qualified estimate": "5)缺乏依据的估算",
+ "5) Representativeness unknown": "5)代表性未知",
+ "Amount differs from mean": "数值与平均值不一致",
+ "Available plugins:": "可用插件:",
+ "BW2Package Files (*.bw2package);; All Files (*.*)": "BW2Package 文件 (*.bw2package);; 所有文件 (*.*)",
+ "Browse": "浏览",
+ "Cannot connect to the internet, please try again later.": "无法连接到互联网,请稍后重试。",
+ "Choose biosphere version": "选择生物圈数据库版本",
+ "Choose ecoinvent version and system model": "选择 ecoinvent 版本和系统模型",
+ "Choose how you want to set up you project": "选择项目的设置方式",
+ "Choose version": "选择版本",
+ "Completeness": "完整性",
+ "Confirm": "确认",
+ "Could not save changes": "无法保存更改",
+ "Database export wizard": "数据库导出向导",
+ "Database selection:": "数据库选择:",
+ "Database:": "数据库:",
+ "Distribution:": "分布:",
+ "Do you want to update the 'amount' field to match mean?\nAmount: {amount}\tMean: {mean}": "是否要更新“amount”字段以匹配平均值?\n数值:{amount}\t平均值:{mean}",
+ "Done": "完成",
+ "Excel Files (*.xlsx);; All Files (*.*)": "Excel 文件 (*.xlsx);; 所有文件 (*.*)",
+ "Export database": "导出数据库",
+ "Exported as:": "导出格式:",
+ "Exported data is stored in the directory below:": "导出的数据将保存在以下位置:",
+ "Fill out or change required parameters": "填写或修改所需参数",
+ "Further technological correlation": "进一步的技术相关性",
+ "Geographical correlation": "地理相关性",
+ "Invalid username and/or password, please try again.": "用户名和/或密码无效,请重试。",
+ "Loc (ln(mean)):": "位置参数 Loc(ln(平均值)):",
+ "Loc / alpha:": "Loc / α:",
+ "Loc / offset:": "Loc / 偏移量:",
+ "Loc:": "位置参数(Loc):",
+ "Login with your ecoinvent credentials to authorize the download": "使用您的 ecoinvent 凭据登录,以授权下载",
+ "Login": "登录",
+ "Maximum:": "最大值:",
+ "Mean:": "平均值:",
+ "Minimum:": "最小值:",
+ "Mode:": "众数:",
+ "Plugins manager": "插件管理器",
+ "Project Setup": "项目设置",
+ "Reliability": "可靠性",
+ "Save database": "保存数据库",
+ "Select pedigree values": "选择谱系矩阵评分",
+ "Select the uncertainty distribution": "选择不确定性分布",
+ "Setting up": "正在设置",
+ "Setting up your project": "正在设置项目",
+ "Setup type": "设置类型",
+ "Shape:": "形状参数:",
+ "Sigma/scale:": "Sigma/尺度参数:",
+ "Temporal correlation": "时间相关性",
+ "Uncertainty": "不确定性",
+ "Unknown connection error, try again later.": "未知连接错误,请稍后重试。",
+ "Use pedigree": "使用谱系矩阵",
+ "ecoinvent and Biosphere3": "ecoinvent 和 Biosphere3",
+ "ecoinvent password": "ecoinvent 密码",
+ "ecoinvent username": "ecoinvent 用户名"
+}
diff --git a/activity_browser/ui/figures.py b/activity_browser/ui/figures.py
index b3597f9bd..2e44810e9 100644
--- a/activity_browser/ui/figures.py
+++ b/activity_browser/ui/figures.py
@@ -11,19 +11,158 @@
from PySide2 import QtWidgets
from activity_browser.mod.bw2data import methods
+from activity_browser.i18n import _, current_language
from activity_browser.utils import savefilepath
from ..bwutils.commontasks import wrap_text
log = getLogger(__name__)
+
+CHINESE_PLOT_FONTS = (
+ "Noto Sans CJK SC",
+ "Noto Sans SC",
+ "Source Han Sans SC",
+ "Microsoft YaHei",
+ "PingFang SC",
+ "Hiragino Sans GB",
+ "SimHei",
+ "WenQuanYi Zen Hei",
+ "Arial Unicode MS",
+ "DejaVu Sans",
+)
+
+
+def configure_plot_fonts(language: str = None) -> None:
+ """Add common CJK-capable fallbacks when the Chinese UI is active."""
+
+ if (language or current_language()) != "zh_CN":
+ return
+ existing = list(plt.rcParams["font.sans-serif"])
+ plt.rcParams["font.sans-serif"] = list(
+ dict.fromkeys((*CHINESE_PLOT_FONTS, *existing))
+ )
+ # Several otherwise suitable CJK fonts omit the Unicode minus glyph.
+ plt.rcParams["axes.unicode_minus"] = False
+
+
+configure_plot_fonts()
+
+
+def prepare_contribution_plot_dataframe(df: pd.DataFrame):
+ """Build a plotting copy while preserving user-provided labels.
+
+ The first three contribution rows are program-defined. Row position,
+ rather than spelling, distinguishes them from scientific data that may
+ legitimately be named ``Score`` or ``Rest (+)``.
+ """
+
+ source = df.iloc[:, ::-1]
+ raw_index = [str(item) for item in source["index"]]
+ fixed_prefix = tuple(raw_index[:3]) in {
+ ("Score", "Rest (+)", "Rest (-)"),
+ ("Total", "Rest (+)", "Rest (-)"),
+ }
+ # Selecting numeric columns by dtype avoids label-based removal when a
+ # result column happens to share a name with a metadata column.
+ dfp = source.select_dtypes(include=np.number).copy()
+
+ if fixed_prefix and raw_index[0] == "Score":
+ dfp = dfp.iloc[1:]
+ raw_index = raw_index[1:]
+ fixed_display_rows = 2
+ else:
+ fixed_display_rows = 3 if fixed_prefix else 0
+
+ keep_rows = ~(dfp == 0).all(axis=1)
+ dfp = dfp.iloc[keep_rows.to_numpy()]
+ display_index = []
+ fixed_rest_positions = []
+ for position, (raw_label, keep_row) in enumerate(
+ zip(raw_index, keep_rows.tolist())
+ ):
+ if not keep_row:
+ continue
+ is_fixed = position < fixed_display_rows
+ display_label = _(raw_label) if is_fixed else raw_label
+ if is_fixed and raw_label in {"Rest (+)", "Rest (-)"}:
+ fixed_rest_positions.append(len(display_index))
+ display_index.append(wrap_text(str(display_label), max_length=40))
+
+ dfp.index = pd.Index(display_index).str.strip("_ \n\t")
+ dfp.columns = pd.Index(
+ [wrap_text(str(item), max_length=40) for item in dfp.columns]
+ ).str.strip("_ \n\t")
+ return dfp, tuple(fixed_rest_positions)
+
+
+def prepare_lca_results_plot_dataframe(df: pd.DataFrame) -> pd.DataFrame:
+ """Build a heatmap copy without matching program rows by label alone."""
+
+ source = df.copy()
+ raw_index = [str(item) for item in source["index"]]
+ fixed_prefix = tuple(raw_index[:3]) in {
+ ("Score", "Rest (+)", "Rest (-)"),
+ ("Total", "Rest (+)", "Rest (-)"),
+ }
+
+ # The overview's first numeric ``amount`` column is metadata. Selecting
+ # all other numeric columns by position preserves a later scientific result
+ # column with the same spelling.
+ amount_section = next(
+ (
+ section
+ for section, value in enumerate(source.columns)
+ if value == "amount"
+ and pd.api.types.is_numeric_dtype(source.iloc[:, section])
+ ),
+ None,
+ )
+ has_overview_metadata = any(
+ value == "database"
+ and amount_section is not None
+ and section > amount_section
+ and not pd.api.types.is_numeric_dtype(source.iloc[:, section])
+ for section, value in enumerate(source.columns)
+ )
+ fixed_amount_section = amount_section if has_overview_metadata else None
+ numeric_sections = [
+ section
+ for section in range(source.shape[1])
+ if pd.api.types.is_numeric_dtype(source.iloc[:, section])
+ and section != fixed_amount_section
+ ]
+ dfp = source.iloc[:, numeric_sections].copy()
+
+ if fixed_prefix and raw_index[0] == "Score":
+ dfp = dfp.iloc[1:]
+ raw_index = raw_index[1:]
+ fixed_display_rows = 2
+ else:
+ fixed_display_rows = 3 if fixed_prefix else 0
+
+ dfp.index = pd.Index(
+ [
+ wrap_text(
+ str(_(label) if position < fixed_display_rows else label),
+ max_length=40,
+ )
+ for position, label in enumerate(raw_index)
+ ]
+ )
+ dfp.columns = pd.Index(
+ [wrap_text(str(item), max_length=20) for item in dfp.columns]
+ )
+ return dfp
+
+
# todo: sizing of the figures needs to be improved and systematized...
# todo: Bokeh is a potential alternative as it allows interactive visualizations,
# but this issue needs to be resolved first: https://github.com/bokeh/bokeh/issues/8169
class Plot(QtWidgets.QWidget):
- ALL_FILTER = "All Files (*.*)"
+ ALL_FILTER = _("All files (*.*)")
PNG_FILTER = "PNG (*.png)"
SVG_FILTER = "SVG (*.svg)"
@@ -35,7 +174,7 @@ def __init__(self, parent=None):
self.canvas = FigureCanvasQTAgg(self.figure)
self.canvas.setMinimumHeight(0)
self.ax = self.figure.add_subplot(111) # create an axis
- self.plot_name = "Figure"
+ self.plot_name = _("Figure")
# set the layout
layout = QtWidgets.QVBoxLayout()
@@ -83,7 +222,7 @@ class LCAResultsBarChart(Plot):
def __init__(self, parent=None):
super().__init__(parent)
- self.plot_name = "LCA scores"
+ self.plot_name = _("LCA scores")
def plot(self, df: pd.DataFrame, method: tuple, labels: list):
self.reset_plot()
@@ -113,7 +252,7 @@ def plot(self, df: pd.DataFrame, method: tuple, labels: list):
class LCAResultsPlot(Plot):
def __init__(self, parent=None):
super().__init__(parent)
- self.plot_name = "LCA heatmap"
+ self.plot_name = _("LCA heatmap")
def plot(self, df: pd.DataFrame, invert_plot: bool = False):
"""Plot a heatmap grid of the different impact categories and reference flows."""
@@ -121,19 +260,7 @@ def plot(self, df: pd.DataFrame, invert_plot: bool = False):
# because of the colorbar which does not get removed by the ax.clear()
self.reset_plot()
- dfp = df.copy()
- dfp.index = dfp["index"]
- dfp.drop(
- dfp.select_dtypes(["object"]), axis=1, inplace=True
- ) # get rid of all non-numeric columns (metadata)
- if "amount" in dfp.columns:
- dfp.drop(["amount"], axis=1, inplace=True) # Drop the 'amount' col
- if "Score" in dfp.index:
- dfp.drop("Score", inplace=True)
-
- # avoid figures getting too large horizontally
- dfp.index = [wrap_text(i, max_length=40) for i in dfp.index]
- dfp.columns = [wrap_text(i, max_length=20) for i in dfp.columns]
+ dfp = prepare_lca_results_plot_dataframe(df)
prop = dfp.divide(dfp.abs().max(axis=0)).multiply(100)
dfp.replace(np.nan, 0, inplace=True)
if invert_plot:
@@ -179,23 +306,15 @@ class ContributionPlot(Plot):
def __init__(self, parent=None):
super().__init__(parent)
- self.plot_name = "Contributions"
+ self.plot_name = _("Contributions")
self.parent = parent
- def plot(self, df: pd.DataFrame, unit: str = None):
+ def plot(
+ self, df: pd.DataFrame, unit: str = None, translate_unit: bool = False
+ ):
"""Plot a horizontal stacked bar chart of contributions,
add 'total' marker if both positive and negative results are present."""
- dfp = df.copy()
- dfp = dfp.iloc[:, ::-1] # reverse column names so they align with calculation setup and rest of results
-
- dfp.index = dfp["index"]
- dfp.drop(
- dfp.select_dtypes(["object"]), axis=1, inplace=True
- ) # get rid of all non-numeric columns (metadata)
- if "Score" in dfp.index:
- dfp.drop("Score", inplace=True)
- # drop rows if all values are 0
- dfp = dfp.loc[~(dfp == 0).all(axis=1)]
+ dfp, fixed_rest_positions = prepare_contribution_plot_dataframe(df)
self.ax.clear()
canvas_width_inches, canvas_height_inches = self.get_canvas_size_in_inches()
@@ -203,21 +322,16 @@ def plot(self, df: pd.DataFrame, unit: str = None):
# print('Optimal Contribution plot height:', optimal_height_inches)
self.figure.set_size_inches(canvas_width_inches, optimal_height_inches)
- # avoid figures getting too large horizontally
- dfp.index = pd.Index([wrap_text(str(i), max_length=40) for i in dfp.index])
- dfp.columns = pd.Index([wrap_text(i, max_length=40) for i in dfp.columns])
- # Strip invalid characters from the ends of row/column headers
- dfp.index = dfp.index.str.strip("_ \n\t")
- dfp.columns = dfp.columns.str.strip("_ \n\t")
-
# set colormap to use
items = dfp.shape[0] # how many contribution items
# skip grey and black at start/end of cmap
- cmap = plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1]
- colors = {item: color for item, color in zip(dfp.index, cmap)}
- # overwrite rest values to grey
- colors["Rest (+)"] = [0.8, 0.8, 0.8, 1.]
- colors["Rest (-)"] = [0.8, 0.8, 0.8, 1.]
+ colors = list(
+ plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1]
+ )
+ # Colour only the two program-defined rest rows grey. A scientific row
+ # with the same text keeps its independently assigned colour.
+ for position in fixed_rest_positions:
+ colors[position] = [0.8, 0.8, 0.8, 1.0]
dfp.T.plot.barh(
stacked=True,
@@ -227,7 +341,7 @@ def plot(self, df: pd.DataFrame, unit: str = None):
)
self.ax.tick_params(labelsize=8)
if unit:
- self.ax.set_xlabel(unit)
+ self.ax.set_xlabel(_(unit) if translate_unit else unit)
# show legend if not too many items
if not dfp.shape[0] >= self.MAX_LEGEND:
@@ -339,7 +453,7 @@ class MonteCarloPlot(Plot):
def __init__(self, parent=None):
super().__init__(parent)
- self.plot_name = "Monte Carlo"
+ self.plot_name = _("Monte Carlo")
def plot(self, df: pd.DataFrame, method: tuple):
self.ax.clear()
@@ -358,7 +472,7 @@ def plot(self, df: pd.DataFrame, method: tuple):
self.ax.axvline(df[col].mean(), color=color)
self.ax.set_xlabel(methods[method]["unit"])
- self.ax.set_ylabel("Probability")
+ self.ax.set_ylabel(_("Probability"))
self.ax.legend(
loc="upper center",
bbox_to_anchor=(0.5, -0.07),
@@ -370,7 +484,7 @@ def plot(self, df: pd.DataFrame, method: tuple):
class SimpleDistributionPlot(Plot):
- def plot(self, data: np.ndarray, mean: float, label: str = "Value"):
+ def plot(self, data: np.ndarray, mean: float, label: str = None):
self.reset_plot()
try:
sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none")
@@ -379,11 +493,11 @@ def plot(self, data: np.ndarray, mean: float, label: str = "Value"):
sns.histplot(
data.T, kde=False, stat="density", ax=self.ax, edgecolor="none"
)
- self.ax.set_xlabel(label)
- self.ax.set_ylabel("Probability density")
+ self.ax.set_xlabel(label or _("Value"))
+ self.ax.set_ylabel(_("Probability density"))
# Add vertical line at given mean of x-axis
- self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98)
+ self.ax.axvline(mean, label=_("Mean / amount"), c="r", ymax=0.98)
self.ax.legend(loc="upper right")
- _, height = self.canvas.get_width_height()
+ _width, height = self.canvas.get_width_height()
self.setMinimumHeight(height / 2)
self.canvas.draw()
diff --git a/activity_browser/ui/menu_bar.py b/activity_browser/ui/menu_bar.py
index bda2dbcb5..aa5403278 100644
--- a/activity_browser/ui/menu_bar.py
+++ b/activity_browser/ui/menu_bar.py
@@ -5,12 +5,20 @@
from PySide2.QtCore import QSize, QUrl, Slot
from activity_browser import actions, signals, application, info
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from .icons import qicons
AB_BW25 = True if os.environ.get("AB_BW25", False) else False
+# Keep menu signal payloads independent from translated action labels. The
+# values mirror ``layouts.panels.panel.TabId`` without importing the layouts
+# package here (which would introduce an import cycle while MainWindow loads).
+GRAPH_EXPLORER_TAB_ID = "right.graph_explorer"
+HISTORY_TAB_ID = "left.history"
+WELCOME_TAB_ID = "right.welcome"
+
class MenuBar(QtWidgets.QMenuBar):
"""
@@ -33,7 +41,7 @@ class ProjectMenu(QtWidgets.QMenu):
def __init__(self, parent=None) -> None:
super().__init__(parent)
- self.setTitle("&Project")
+ self.setTitle(_("&Project"))
self.new_proj_action = actions.ProjectNew.get_QAction()
self.dup_proj_action = actions.ProjectDuplicate.get_QAction()
@@ -82,22 +90,22 @@ class ViewMenu(QtWidgets.QMenu):
def __init__(self, parent=None) -> None:
super().__init__(parent)
- self.setTitle("&View")
+ self.setTitle(_("&View"))
self.addAction(
qicons.graph_explorer,
- "&Graph Explorer",
- lambda: signals.toggle_show_or_hide_tab.emit("Graph Explorer"),
+ _("&Graph Explorer"),
+ lambda: signals.toggle_show_or_hide_tab.emit(GRAPH_EXPLORER_TAB_ID),
)
self.addAction(
qicons.history,
- "&Activity History",
- lambda: signals.toggle_show_or_hide_tab.emit("History"),
+ _("&Activity History"),
+ lambda: signals.toggle_show_or_hide_tab.emit(HISTORY_TAB_ID),
)
self.addAction(
qicons.welcome,
- "&Welcome screen",
- lambda: signals.toggle_show_or_hide_tab.emit("Welcome"),
+ _("&Welcome screen"),
+ lambda: signals.toggle_show_or_hide_tab.emit(WELCOME_TAB_ID),
)
@@ -108,7 +116,7 @@ class ToolsMenu(QtWidgets.QMenu):
def __init__(self, parent=None) -> None:
super().__init__(parent)
- self.setTitle("&Tools")
+ self.setTitle(_("&Tools"))
self.manage_plugins_action = actions.PluginWizardOpen.get_QAction()
@@ -122,38 +130,45 @@ class HelpMenu(QtWidgets.QMenu):
def __init__(self, parent=None) -> None:
super().__init__(parent)
- self.setTitle("&Help")
+ self.setTitle(_("&Help"))
self.addAction(
- qicons.ab, "&About Activity Browser", self.about
+ qicons.ab, _("&About Activity Browser"), self.about
)
self.addAction(
- "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(application.main_window)
+ _("&About Qt"),
+ lambda: QtWidgets.QMessageBox.aboutQt(application.main_window),
)
self.addAction(
- qicons.question, "&Get help on the wiki", self.open_wiki
+ qicons.question, _("&Get help on the wiki"), self.open_wiki
)
self.addAction(
- qicons.issue, "&Report an idea/issue on GitHub", self.raise_issue_github
+ qicons.issue,
+ _("&Report an idea/issue on GitHub"),
+ self.raise_issue_github,
)
def about(self):
"""Displays an 'about' window to the user containing e.g. the version of the AB and copyright info"""
# set the window text in html format
- text = f"""
- Activity Browser - a graphical interface for Brightway2.
- Application version: {version("activity_browser")}
- bw2data version: {version("bw2data")}
- bw2io version: {version("bw2calc")}
- bw2calc version: {version("bw2io")}
- All development happens on github.
- For copyright information please see the copyright on this page.
- For license information please see the copyright on this page.
- """
+ text = _(
+ "Activity Browser - a graphical interface for Brightway2.
"
+ "Application version: {application_version}
"
+ "bw2data version: {bw2data_version}
"
+ "bw2io version: {bw2io_version}
"
+ "bw2calc version: {bw2calc_version}
"
+ "All development happens on github.
"
+ "For copyright information please see the copyright on this page.
"
+ "For license information please see the copyright on this page.
",
+ application_version=version("activity_browser"),
+ bw2data_version=version("bw2data"),
+ bw2io_version=version("bw2calc"),
+ bw2calc_version=version("bw2io"),
+ )
# set up the window
about_window = QtWidgets.QMessageBox(parent=application.main_window)
- about_window.setWindowTitle("About the Activity Browser")
+ about_window.setWindowTitle(_("About the Activity Browser"))
about_window.setIconPixmap(qicons.ab.pixmap(QSize(150, 150)))
about_window.setText(text)
@@ -181,7 +196,7 @@ class ProjectSelectionMenu(QtWidgets.QMenu):
"""
def __init__(self, parent=None):
super().__init__(parent)
- self.setTitle("Open project")
+ self.setTitle(_("Open project"))
self.populate()
self.aboutToShow.connect(self.populate)
@@ -222,8 +237,7 @@ class MigrationsMenu(QtWidgets.QMenu):
def __init__(self, parent=None) -> None:
super().__init__(parent)
- self.setTitle("Migrations")
+ self.setTitle(_("Migrations"))
self.install_migrations_action = actions.MigrationsInstall.get_QAction()
self.addAction(self.install_migrations_action)
-
diff --git a/activity_browser/ui/statusbar.py b/activity_browser/ui/statusbar.py
index 31f4d56d8..dbda1e82c 100644
--- a/activity_browser/ui/statusbar.py
+++ b/activity_browser/ui/statusbar.py
@@ -4,6 +4,7 @@
from PySide2.QtWidgets import QLabel, QStatusBar
from activity_browser import signals
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
log = getLogger(__name__)
@@ -12,9 +13,9 @@
class Statusbar(QStatusBar):
def __init__(self, window):
super().__init__(parent=window)
- self.status_message_left = QLabel("Welcome")
- self.status_message_right = QLabel("Database")
- self.status_message_center = QLabel("Project")
+ self.status_message_left = QLabel(_("Welcome"))
+ self.status_message_right = QLabel(_("Database"))
+ self.status_message_center = QLabel(_("Project"))
self.addWidget(self.status_message_left, 1)
self.addWidget(self.status_message_center, 2)
@@ -43,9 +44,9 @@ def right(self, message):
@Slot(name="updateProjectStatus")
def update_project(self):
- self.center(f"Project: {bd.projects.current}")
- self.right("Database: None")
+ self.center(_("Project: {project}", project=bd.projects.current))
+ self.right(_("Database: None"))
@Slot(str, name="setDatabaseName")
def set_database(self, name):
- self.right("Database: {}".format(name))
+ self.right(_("Database: {database}", database=name))
diff --git a/activity_browser/ui/tables/LCA_setup.py b/activity_browser/ui/tables/LCA_setup.py
index 97d55b2d4..70f1ec483 100644
--- a/activity_browser/ui/tables/LCA_setup.py
+++ b/activity_browser/ui/tables/LCA_setup.py
@@ -4,6 +4,7 @@
from PySide2.QtCore import Qt, Slot
from activity_browser import signals, actions
+from activity_browser.i18n import _
from activity_browser.mod.bw2data import calculation_setups
from ..icons import qicons
@@ -86,9 +87,11 @@ def __init__(self, parent=None):
self.model.updated.connect(lambda: self.resizeColumnToContents(2))
self.model.updated.connect(lambda: self.resizeColumnToContents(3))
self.setToolTip(
- "Drag Activities from the Activities table to include them as a reference flow\n"
- "Click and drag to re-order individual rows of the table\n"
- "Hold CTRL and click to select multiple rows to open or delete them."
+ _(
+ "Drag activities from the activities table to include them as reference flows.\n"
+ "Click and drag to reorder rows.\n"
+ "Hold Ctrl while clicking to select multiple rows to open or remove."
+ )
)
@Slot(name="openActivities")
@@ -123,8 +126,8 @@ def contextMenuEvent(self, event) -> None:
if self.indexAt(event.pos()).row() == -1:
return
menu = QtWidgets.QMenu()
- menu.addAction(qicons.right, "Open activity", self.open_activities)
- menu.addAction(qicons.delete, "Remove row", self.delete_rows)
+ menu.addAction(qicons.right, _("Open activity"), self.open_activities)
+ menu.addAction(qicons.delete, _("Remove row"), self.delete_rows)
menu.exec_(event.globalPos())
def dragEnterEvent(self, event):
@@ -173,9 +176,11 @@ def __init__(self, parent=None):
self.model.updated.connect(lambda: self.setColumnHidden(3, True))
self.model.updated.connect(lambda: self.resizeColumnToContents(0))
self.setToolTip(
- "Drag impact categories from the impact categories tree/table to include them \n"
- "Click and drag to re-order individual rows of the table\n"
- "Hold CTRL and click to select multiple rows to open or delete them."
+ _(
+ "Drag impact categories from the tree or table to include them.\n"
+ "Click and drag to reorder rows.\n"
+ "Hold Ctrl while clicking to select multiple rows to open or remove."
+ )
)
self.open_method_action = actions.MethodOpen.get_QAction(self.selected_methods)
@@ -205,7 +210,7 @@ def contextMenuEvent(self, event) -> None:
menu.addAction(self.open_method_action)
menu.addAction(
qicons.delete,
- "Remove rows",
+ _("Remove rows"),
lambda: self.model.delete_rows(self.selectedIndexes()),
)
diff --git a/activity_browser/ui/tables/activity.py b/activity_browser/ui/tables/activity.py
index f41dcf616..c399b3aca 100644
--- a/activity_browser/ui/tables/activity.py
+++ b/activity_browser/ui/tables/activity.py
@@ -5,6 +5,7 @@
from PySide2.QtCore import Slot
from activity_browser import actions
+from activity_browser.i18n import _
from ..icons import qicons
from .delegates import *
@@ -118,7 +119,7 @@ def contextMenuEvent(self, event) -> None:
menu.addAction(self.remove_formula_action)
# Submenu copy to clipboard
submenu_copy = QtWidgets.QMenu(menu)
- submenu_copy.setTitle("Copy to clipboard")
+ submenu_copy.setTitle(_("Copy to clipboard"))
submenu_copy.setIcon(qicons.copy_to_clipboard)
submenu_copy.addAction(self.copy_exchanges_for_SDF_action)
menu.addMenu(submenu_copy)
@@ -166,7 +167,7 @@ def contextMenuEvent(self, event) -> None:
if self.indexAt(event.pos()).row() == -1:
return
menu = QtWidgets.QMenu()
- menu.addAction(qicons.right, "Open activities", self.open_activities)
+ menu.addAction(qicons.right, _("Open activities"), self.open_activities)
menu.addAction(self.modify_uncertainty_action)
menu.addSeparator()
menu.addAction(self.delete_exchange_action)
@@ -174,7 +175,7 @@ def contextMenuEvent(self, event) -> None:
menu.addAction(self.remove_uncertainty_action)
# Submenu copy to clipboard
submenu_copy = QtWidgets.QMenu(menu)
- submenu_copy.setTitle("Copy to clipboard")
+ submenu_copy.setTitle(_("Copy to clipboard"))
submenu_copy.setIcon(qicons.copy_to_clipboard)
submenu_copy.addAction(self.copy_exchanges_for_SDF_action)
menu.addMenu(submenu_copy)
@@ -230,7 +231,7 @@ def contextMenuEvent(self, event) -> None:
# Submenu copy to clipboard
submenu_copy = QtWidgets.QMenu(menu)
- submenu_copy.setTitle("Copy to clipboard")
+ submenu_copy.setTitle(_("Copy to clipboard"))
submenu_copy.setIcon(qicons.copy_to_clipboard)
submenu_copy.addAction(self.copy_exchanges_for_SDF_action)
menu.addMenu(submenu_copy)
@@ -260,5 +261,5 @@ def contextMenuEvent(self, event) -> None:
if self.indexAt(event.pos()).row() == -1:
return
menu = QtWidgets.QMenu()
- menu.addAction(qicons.right, "Open activities", self.open_activities)
+ menu.addAction(qicons.right, _("Open activities"), self.open_activities)
menu.exec_(event.globalPos())
diff --git a/activity_browser/ui/tables/delegates/formula.py b/activity_browser/ui/tables/delegates/formula.py
index cc036fc7b..988af0be3 100644
--- a/activity_browser/ui/tables/delegates/formula.py
+++ b/activity_browser/ui/tables/delegates/formula.py
@@ -6,6 +6,7 @@
from PySide2.QtCore import Signal, Slot
from activity_browser import actions, signals
+from activity_browser.i18n import _
class CalculatorButtons(QtWidgets.QWidget):
@@ -19,26 +20,23 @@ class CalculatorButtons(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
- self.explain_text = """
-In addition to the other buttons on this calculator, the parameter formula
-can make use of a large number of Python and Numpy functions, with Numpy
-overriding Python where the function names are the same.
-
-For a more complete list see the `math` module in the Python documentation
-or `ufuncs` in de Numpy documentation.
-
-Keep in mind that the result of a formula must be a scalar value!
-"""
+ self.explain_text = _(
+ "In addition to the buttons on this calculator, parameter formulas can "
+ "use many Python and NumPy functions. NumPy takes precedence where "
+ "function names are the same.\n\nFor a more complete list, see the "
+ "Python documentation for the math module or NumPy ufuncs.\n\nThe "
+ "result of a formula must be a scalar value."
+ )
rows = [
[
- ("+", "Add", lambda: self.button_press.emit(" + ")),
- ("-", "Subtract", lambda: self.button_press.emit(" - ")),
- ("*", "Multiply", lambda: self.button_press.emit(" * ")),
+ ("+", _("Add"), lambda: self.button_press.emit(" + ")),
+ ("-", _("Subtract"), lambda: self.button_press.emit(" - ")),
+ ("*", _("Multiply"), lambda: self.button_press.emit(" * ")),
],
[
- ("/", "Divide", lambda: self.button_press.emit(" / ")),
- ("x²", "X to the power of 2", lambda: self.button_press.emit(" ** 2 ")),
- ("More...", "Additional functions", self.explanation),
+ ("/", _("Divide"), lambda: self.button_press.emit(" / ")),
+ ("x²", _("X to the power of 2"), lambda: self.button_press.emit(" ** 2 ")),
+ (_("More..."), _("Additional functions"), self.explanation),
],
]
# Construct the layout from the list of lists above.
@@ -61,7 +59,7 @@ def __init__(self, parent=None):
def explanation(self):
return QtWidgets.QMessageBox.question(
self,
- "More...",
+ _("More..."),
self.explain_text,
QtWidgets.QMessageBox.Ok,
QtWidgets.QMessageBox.Ok,
@@ -71,7 +69,7 @@ def explanation(self):
class FormulaDialog(QtWidgets.QDialog):
def __init__(self, parent=None, flags=QtCore.Qt.Window):
super().__init__(parent=parent, f=flags)
- self.setWindowTitle("Build a formula")
+ self.setWindowTitle(_("Build a formula"))
self.setWindowModality(QtCore.Qt.ApplicationModal)
self.interpreter = None
self.key = ("", "")
@@ -121,7 +119,7 @@ def insert_parameters(self, items) -> None:
"""
model = self.parameters.model()
model.clear()
- model.setHorizontalHeaderLabels(["Name", "Amount", "Type"])
+ model.setHorizontalHeaderLabels([_("Name"), _("Amount"), _("Type")])
for x, item in enumerate(items):
for y, value in enumerate(item):
model_item = QtGui.QStandardItem(str(value))
diff --git a/activity_browser/ui/tables/delegates/list.py b/activity_browser/ui/tables/delegates/list.py
index 0dcdc41cf..b0a7e144e 100644
--- a/activity_browser/ui/tables/delegates/list.py
+++ b/activity_browser/ui/tables/delegates/list.py
@@ -4,6 +4,8 @@
from PySide2 import QtCore, QtGui, QtWidgets
+from activity_browser.i18n import _
+
class OrderedListInputDialog(QtWidgets.QDialog):
"""Mostly cobbled together from: https://stackoverflow.com/a/41310284
@@ -12,7 +14,7 @@ class OrderedListInputDialog(QtWidgets.QDialog):
def __init__(self, parent=None, flags=QtCore.Qt.Window):
super().__init__(parent=parent, f=flags)
- self.setWindowTitle("Select and order items")
+ self.setWindowTitle(_("Select and order items"))
form = QtWidgets.QFormLayout(self)
self.list_view = QtWidgets.QListView(self)
diff --git a/activity_browser/ui/tables/delegates/uncertainty.py b/activity_browser/ui/tables/delegates/uncertainty.py
index a6e203ec9..38678e298 100644
--- a/activity_browser/ui/tables/delegates/uncertainty.py
+++ b/activity_browser/ui/tables/delegates/uncertainty.py
@@ -2,6 +2,8 @@
from PySide2 import QtCore, QtWidgets
from stats_arrays import uncertainty_choices as uc
+from activity_browser.i18n import _
+
from ....signals import signals
@@ -23,9 +25,10 @@ def displayText(self, value, locale):
either cannot be found or the value is 'nan' (when id is not set)
"""
try:
- return uc[int(value)].description
+ description = uc[int(value)].description
except (IndexError, ValueError):
- return uc[0].description
+ description = uc[0].description
+ return _(description)
def createEditor(self, parent, option, index):
"""Simply use the wizard for updating uncertainties. Send a signal."""
@@ -41,6 +44,9 @@ def setModelData(
model: QtCore.QAbstractItemModel,
index: QtCore.QModelIndex,
):
- """Read the current text and look up the actual ID of that uncertainty type."""
- uc_id = self.choices.get(editor.currentText(), 0)
+ """Store the stable item ID, independent of its translated label."""
+ uc_id = editor.currentData()
+ if uc_id is None:
+ # Compatibility with an older editor that stored only English text.
+ uc_id = self.choices.get(editor.currentText(), 0)
model.setData(index, uc_id, QtCore.Qt.EditRole)
diff --git a/activity_browser/ui/tables/history.py b/activity_browser/ui/tables/history.py
index 9534fe524..924e4ff43 100644
--- a/activity_browser/ui/tables/history.py
+++ b/activity_browser/ui/tables/history.py
@@ -2,6 +2,8 @@
from PySide2.QtCore import Slot
from PySide2.QtWidgets import QAbstractItemView, QMenu
+from activity_browser.i18n import _
+
from ..icons import qicons
from .models import ActivitiesHistoryModel
from .views import ABDataFrameView
@@ -20,7 +22,7 @@ def contextMenuEvent(self, event) -> None:
if self.indexAt(event.pos()).row() == -1:
return
menu = QMenu(self)
- menu.addAction(qicons.right, "Open in new tab", self.open_tab)
+ menu.addAction(qicons.right, _("Open in new tab"), self.open_tab)
menu.exec_(event.globalPos())
@Slot(name="openTab")
diff --git a/activity_browser/ui/tables/impact_categories.py b/activity_browser/ui/tables/impact_categories.py
index b532c952a..0a8cb85f5 100644
--- a/activity_browser/ui/tables/impact_categories.py
+++ b/activity_browser/ui/tables/impact_categories.py
@@ -5,6 +5,7 @@
from PySide2.QtCore import QModelIndex, Slot, Qt
from activity_browser import actions
+from activity_browser.i18n import _
from activity_browser.mod.bw2data import methods
from ...signals import signals
@@ -36,14 +37,22 @@ def __init__(self, parent=None):
self.delete_method_action = actions.MethodDelete.get_QAction(self.selected_methods)
self.connect_signals()
+ self.update_proxy_model()
+ self.hide_method_column()
def connect_signals(self):
self.doubleClicked.connect(
lambda p: signals.method_selected.emit(self.model.get_method(p))
)
self.model.updated.connect(self.update_proxy_model)
+ self.model.updated.connect(self.hide_method_column)
methods.metadata_changed.connect(self.sync)
+ @Slot(name="hideMethodColumn")
+ def hide_method_column(self) -> None:
+ """Keep the internal method tuple out of the visible list view."""
+ self.setColumnHidden(self.model.method_col, True)
+
def selected_methods(self) -> list:
"""Returns a list of all the currently selected methods."""
return [self.model.get_method(p) for p in self.selectedIndexes()]
@@ -59,7 +68,7 @@ def contextMenuEvent(self, event) -> None:
menu = QtWidgets.QMenu(self)
menu.addAction(
qicons.edit,
- "Inspect Impact Category",
+ _("Inspect impact category"),
lambda: signals.method_selected.emit(
self.model.get_method(self.currentIndex())
),
@@ -167,11 +176,17 @@ def contextMenuEvent(self, event) -> None:
menu = QtWidgets.QMenu(self)
if self.tree_level()[0] == "leaf":
- menu.addAction(qicons.edit, "Inspect Impact Category", self.method_selected)
+ menu.addAction(
+ qicons.edit, _("Inspect impact category"), self.method_selected
+ )
else:
- menu.addAction(qicons.forward, "Expand all sub levels", self.expand_branch)
menu.addAction(
- qicons.backward, "Collapse all sub levels", self.collapse_branch
+ qicons.forward, _("Expand all sub levels"), self.expand_branch
+ )
+ menu.addAction(
+ qicons.backward,
+ _("Collapse all sub levels"),
+ self.collapse_branch,
)
menu.addSeparator()
@@ -292,7 +307,7 @@ def cell_edited(self) -> None:
cell = self.selectedIndexes()[0]
column = cell.column()
- if self.model.headerData(column, Qt.Horizontal) == 'Amount':
+ if column == self.model.HEADERS.index("Amount"):
# if the column changed is 2 (Amount) --> This is a list in case of future editable columns
new_amount = self.model.get_value(cell)
actions.CFAmountModify.run(self.method_name(), self.selected_cfs(), new_amount)
diff --git a/activity_browser/ui/tables/inventory.py b/activity_browser/ui/tables/inventory.py
index c589ddf66..579ead0b0 100644
--- a/activity_browser/ui/tables/inventory.py
+++ b/activity_browser/ui/tables/inventory.py
@@ -4,6 +4,7 @@
from PySide2.QtCore import Slot
from activity_browser import actions
+from activity_browser.i18n import _
from ...bwutils import AB_metadata
from ...settings import project_settings
@@ -130,7 +131,7 @@ def __init__(self, parent=None):
self.selected_keys
)
self.copy_exchanges_for_SDF_action = QtWidgets.QAction(
- qicons.superstructure, "Exchanges for scenario difference file", None
+ qicons.superstructure, _("Exchanges for scenario difference file"), None
)
self.connect_signals()
@@ -146,24 +147,32 @@ def contextMenuEvent(self, event) -> None:
if self.indexAt(event.pos()).row() == -1 and len(self.model._dataframe) != 0:
return
- if len(self.selected_keys()) > 1:
+ multiple = len(self.selected_keys()) > 1
+ if multiple:
# more than 1 activity is selected
- act = "activities"
self.dup_activity_new_loc_action.setEnabled(False)
self.relink_activity_exch_action.setEnabled(False)
elif len(self.selected_keys()) == 1 and self.db_read_only:
- act = "activity"
self.dup_activity_new_loc_action.setEnabled(False)
self.relink_activity_exch_action.setEnabled(False)
else:
- act = "activity"
self.dup_activity_new_loc_action.setEnabled(True)
self.relink_activity_exch_action.setEnabled(True)
- self.open_activity_action.setText(f"Open {act}")
- self.open_activity_graph_action.setText(f"Open {act} in Graph Explorer")
- self.dup_activity_action.setText(f"Duplicate {act}")
- self.delete_activity_action.setText(f"Delete {act}")
+ self.open_activity_action.setText(
+ _("Open activities") if multiple else _("Open activity")
+ )
+ self.open_activity_graph_action.setText(
+ _("Open activities in Graph Explorer")
+ if multiple
+ else _("Open activity in Graph Explorer")
+ )
+ self.dup_activity_action.setText(
+ _("Duplicate activities") if multiple else _("Duplicate activity")
+ )
+ self.delete_activity_action.setText(
+ _("Delete activities") if multiple else _("Delete activity")
+ )
menu = QtWidgets.QMenu()
@@ -175,14 +184,16 @@ def contextMenuEvent(self, event) -> None:
# submenu duplicates
submenu_dupl = QtWidgets.QMenu(menu)
- submenu_dupl.setTitle(f"Duplicate {act}")
+ submenu_dupl.setTitle(
+ _("Duplicate activities") if multiple else _("Duplicate activity")
+ )
submenu_dupl.setIcon(qicons.copy)
submenu_dupl.addAction(self.dup_activity_action)
submenu_dupl.addAction(self.dup_activity_new_loc_action)
submenu_dupl.addAction(self.dup_other_db_action)
# submenu copy to clipboard
submenu_copy = QtWidgets.QMenu(menu)
- submenu_copy.setTitle("Copy to clipboard")
+ submenu_copy.setTitle(_("Copy to clipboard"))
submenu_copy.setIcon(qicons.copy_to_clipboard)
submenu_copy.addAction(self.copy_exchanges_for_SDF_action)
@@ -317,7 +328,7 @@ def __init__(self, parent=None, database_name=None):
self.selected_keys
)
self.copy_exchanges_for_SDF_action = QtWidgets.QAction(
- qicons.superstructure, "Exchanges for scenario difference file", None
+ qicons.superstructure, _("Exchanges for scenario difference file"), None
)
self.connect_signals()
@@ -349,8 +360,8 @@ def contextMenuEvent(self, event) -> None:
return
# determine enabling of actions based on amount of selected activities
- if len(self.selected_keys()) > 1:
- act = "activities"
+ multiple = len(self.selected_keys()) > 1
+ if multiple:
self.dup_activity_new_loc_action.setEnabled(False)
self.relink_activity_exch_action.setEnabled(False)
if len(self.selected_keys()) > 15:
@@ -361,7 +372,6 @@ def contextMenuEvent(self, event) -> None:
self.open_activity_action.setEnabled(allow_open)
self.open_activity_graph_action.setEnabled(allow_open)
else: # only one activity is selected
- act = "activity"
self.open_activity_action.setEnabled(True)
self.open_activity_graph_action.setEnabled(True)
self.dup_activity_new_loc_action.setEnabled(not self.db_read_only)
@@ -374,31 +384,51 @@ def contextMenuEvent(self, event) -> None:
self.relink_activity_exch_action.setEnabled(not self.db_read_only)
# set plural or singular for activity
- self.open_activity_action.setText(f"Open {act}")
- self.open_activity_graph_action.setText(f"Open {act} in Graph Explorer")
- self.dup_activity_action.setText(f"Duplicate {act}")
- self.delete_activity_action.setText(f"Delete {act}")
- self.relink_activity_exch_action.setText(f"Relink the {act} exchanges")
+ self.open_activity_action.setText(
+ _("Open activities") if multiple else _("Open activity")
+ )
+ self.open_activity_graph_action.setText(
+ _("Open activities in Graph Explorer")
+ if multiple
+ else _("Open activity in Graph Explorer")
+ )
+ self.dup_activity_action.setText(
+ _("Duplicate activities") if multiple else _("Duplicate activity")
+ )
+ self.delete_activity_action.setText(
+ _("Delete activities") if multiple else _("Delete activity")
+ )
+ self.relink_activity_exch_action.setText(
+ _("Relink the selected activities' exchanges")
+ if multiple
+ else _("Relink the activity's exchanges")
+ )
menu = QtWidgets.QMenu(self)
# submenu duplicates
submenu_dupl = QtWidgets.QMenu(menu)
- submenu_dupl.setTitle(f"Duplicate {act}")
+ submenu_dupl.setTitle(
+ _("Duplicate activities") if multiple else _("Duplicate activity")
+ )
submenu_dupl.setIcon(qicons.copy)
submenu_dupl.addAction(self.dup_activity_action)
submenu_dupl.addAction(self.dup_activity_new_loc_action)
submenu_dupl.addAction(self.dup_other_db_action)
# submenu copy to clipboard
submenu_copy = QtWidgets.QMenu(menu)
- submenu_copy.setTitle("Copy to clipboard")
+ submenu_copy.setTitle(_("Copy to clipboard"))
submenu_copy.setIcon(qicons.copy_to_clipboard)
submenu_copy.addAction(self.copy_exchanges_for_SDF_action)
if self.tree_level()[0] != "leaf":
# multiple items are selected
- menu.addAction(qicons.forward, "Expand all sub levels", self.expand_branch)
menu.addAction(
- qicons.backward, "Collapse all sub levels", self.collapse_branch
+ qicons.forward, _("Expand all sub levels"), self.expand_branch
+ )
+ menu.addAction(
+ qicons.backward,
+ _("Collapse all sub levels"),
+ self.collapse_branch,
)
menu.addSeparator()
@@ -488,10 +518,13 @@ def tree_level(self) -> tuple:
'sweet corn')
"""
indexes = self.selectedIndexes()
- if indexes[1].data() != "" or indexes[2].data() != "":
+ if (
+ indexes[1].data(QtCore.Qt.UserRole) != ""
+ or indexes[2].data(QtCore.Qt.UserRole) != ""
+ ):
return "leaf", self.find_levels()
- elif indexes[0].parent().data() is None:
- return "root", indexes[0].data()
+ elif indexes[0].parent().data(QtCore.Qt.UserRole) is None:
+ return "root", indexes[0].data(QtCore.Qt.UserRole)
else:
return "branch", self.find_levels()
@@ -499,16 +532,16 @@ def find_levels(self, level=None) -> list:
"""Find all levels of branch."""
if not level:
idx = self.selectedIndexes()
- if idx[-1].data() != "":
+ if idx[-1].data(QtCore.Qt.UserRole) != "":
level = idx[-1]
else:
level = idx[0]
parent = idx[0].parent()
else:
parent = level.parent()
- levels = [level.data()]
- while parent.data() is not None:
- levels.append(parent.data())
+ levels = [level.data(QtCore.Qt.UserRole)]
+ while parent.data(QtCore.Qt.UserRole) is not None:
+ levels.append(parent.data(QtCore.Qt.UserRole))
parent = parent.parent()
return levels[::-1]
diff --git a/activity_browser/ui/tables/models/activity.py b/activity_browser/ui/tables/models/activity.py
index 71493a99f..8751c6934 100644
--- a/activity_browser/ui/tables/models/activity.py
+++ b/activity_browser/ui/tables/models/activity.py
@@ -23,6 +23,7 @@
class BaseExchangeModel(EditablePandasModel):
COLUMNS = []
+ TRANSLATABLE_HEADERS = ("pedigree",)
# Fields accepted by brightway to be stored in exchange objects.
VALID_FIELDS = {
"amount",
diff --git a/activity_browser/ui/tables/models/base.py b/activity_browser/ui/tables/models/base.py
index fb9db1bb5..f4605aade 100644
--- a/activity_browser/ui/tables/models/base.py
+++ b/activity_browser/ui/tables/models/base.py
@@ -11,11 +11,37 @@
from PySide2.QtGui import QBrush
from activity_browser.bwutils import commontasks as bc
+from activity_browser.i18n import _, current_language
from activity_browser.ui.style import style_item
log = getLogger(__name__)
+class FilterOperator:
+ """Stable filter identifiers shared by views, dialogs, and models."""
+
+ EQUALS = "equals"
+ NOT_EQUALS = "does not equal"
+ CONTAINS = "contains"
+ NOT_CONTAINS = "does not contain"
+ STARTS_WITH = "starts with"
+ NOT_STARTS_WITH = "does not start with"
+ ENDS_WITH = "ends with"
+ NOT_ENDS_WITH = "does not end with"
+ NUM_EQUALS = "="
+ NUM_NOT_EQUALS = "!="
+ GREATER_THAN_OR_EQUAL = ">="
+ LESS_THAN_OR_EQUAL = "<="
+ BETWEEN = "<= x <="
+
+
+class FilterMode:
+ """Stable identifiers for combining filter masks."""
+
+ AND = "AND"
+ OR = "OR"
+
+
class PandasModel(QAbstractTableModel):
"""Abstract pandas table model adapted from
https://stackoverflow.com/a/42955764.
@@ -26,6 +52,8 @@ class PandasModel(QAbstractTableModel):
"""
HEADERS = []
+ TRANSLATABLE_HEADERS = ()
+ TRANSLATABLE_VALUES = ()
updated = Signal()
def __init__(self, df: pd.DataFrame = None, parent=None):
@@ -75,7 +103,18 @@ def data(self, index, role=Qt.DisplayRole):
)
tt_date_flag = True
elif role == Qt.DisplayRole:
- value = arrow.get(value).shift(seconds=time_shift).humanize()
+ value = (
+ arrow.get(value)
+ .shift(seconds=time_shift)
+ .humanize(locale=current_language().replace("_", "-").lower())
+ )
+
+ if (
+ role in (Qt.DisplayRole, Qt.ToolTipRole)
+ and isinstance(value, str)
+ and self.should_translate_value(index, value)
+ ):
+ value = _(value)
# immediately return value in case of DisplayRole or sorting
if role == Qt.DisplayRole or role == "sorting":
@@ -109,12 +148,39 @@ def data(self, index, role=Qt.DisplayRole):
return None
+ def should_translate_value(self, index: QModelIndex, value: str) -> bool:
+ """Return whether a fixed value is interface text at this cell.
+
+ Subclasses can narrow this check by row or column. This matters when
+ user data happens to have the same spelling as a built-in result label.
+ """
+
+ return value in self.TRANSLATABLE_VALUES
+
+ def should_translate_header(self, section: int, value) -> bool:
+ """Return whether a table header is fixed interface text.
+
+ Result tables override this hook because they mix program-defined
+ metadata columns with scientific result columns named by user data.
+ """
+
+ declared = set(self.TRANSLATABLE_HEADERS)
+ declared.update(getattr(self, "HEADERS", ()))
+ declared.update(getattr(self, "COLUMNS", ()))
+ declared.update(getattr(self, "UNCERTAINTY", ()))
+ return value in declared
+
def flags(self, index):
return Qt.ItemIsSelectable | Qt.ItemIsEnabled
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
- return self._dataframe.columns[section]
+ value = self._dataframe.columns[section]
+ return (
+ _(str(value))
+ if self.should_translate_header(section, value)
+ else value
+ )
elif orientation == Qt.Vertical and role == Qt.DisplayRole:
return self._dataframe.index[section]
return None
@@ -153,31 +219,31 @@ def test_query_on_column(
self, test_type: str, col_data: pd.Series, query
) -> pd.Series:
"""Compare query and col_data on test_type, return array with boolean test results."""
- if test_type == "equals":
+ if test_type == FilterOperator.EQUALS:
return col_data == query
- elif test_type == "does not equal":
+ elif test_type == FilterOperator.NOT_EQUALS:
return col_data != query
- elif test_type == "contains":
+ elif test_type == FilterOperator.CONTAINS:
return col_data.str.contains(query, regex=False)
- elif test_type == "does not contain":
+ elif test_type == FilterOperator.NOT_CONTAINS:
return ~col_data.str.contains(query, regex=False)
- elif test_type == "starts with":
+ elif test_type == FilterOperator.STARTS_WITH:
return col_data.str.startswith(query)
- elif test_type == "does not start with":
+ elif test_type == FilterOperator.NOT_STARTS_WITH:
return ~col_data.str.startswith(query)
- elif test_type == "ends with":
+ elif test_type == FilterOperator.ENDS_WITH:
return col_data.str.endswith(query)
- elif test_type == "does not end with":
+ elif test_type == FilterOperator.NOT_ENDS_WITH:
return ~col_data.str.endswith(query)
- elif test_type == "=":
+ elif test_type == FilterOperator.NUM_EQUALS:
return col_data.astype(float) == float(query)
- elif test_type == "!=":
+ elif test_type == FilterOperator.NUM_NOT_EQUALS:
return col_data.astype(float) != float(query)
- elif test_type == ">=":
+ elif test_type == FilterOperator.GREATER_THAN_OR_EQUAL:
return col_data.astype(float) >= float(query)
- elif test_type == "<=":
+ elif test_type == FilterOperator.LESS_THAN_OR_EQUAL:
return col_data.astype(float) <= float(query)
- elif test_type == "<= x <=":
+ elif test_type == FilterOperator.BETWEEN:
return (float(query[0]) <= col_data.astype(float)) & (
col_data.astype(float) <= float(query[1])
)
@@ -229,17 +295,17 @@ def get_filter_mask(self, filters: dict) -> pd.Series:
)
# create or combine new mask within column
- if isinstance(col_mask, pd.Series) and col_mode == "AND":
+ if isinstance(col_mask, pd.Series) and col_mode == FilterMode.AND:
col_mask = col_mask & new_mask
- elif isinstance(col_mask, pd.Series) and col_mode == "OR":
+ elif isinstance(col_mask, pd.Series) and col_mode == FilterMode.OR:
col_mask = col_mask + new_mask
else:
col_mask = new_mask
# create or combine new mask on columns
- if isinstance(all_mask, pd.Series) and all_mode == "AND":
+ if isinstance(all_mask, pd.Series) and all_mode == FilterMode.AND:
all_mask = all_mask & col_mask
- elif isinstance(all_mask, pd.Series) and all_mode == "OR":
+ elif isinstance(all_mask, pd.Series) and all_mode == FilterMode.OR:
all_mask = all_mask + col_mask
else:
all_mask = col_mask
@@ -328,6 +394,7 @@ class BaseTreeModel(QAbstractItemModel):
"""Base Model used to present data for QTreeView."""
HEADERS = []
+ TRANSLATABLE_VALUES = ()
updated = Signal()
def __init__(self, parent=None, *args, **kwargs):
@@ -342,9 +409,12 @@ def data(self, index, role: int = Qt.DisplayRole):
if not index.isValid():
return None
- if role == Qt.DisplayRole:
+ if role in (Qt.DisplayRole, Qt.UserRole):
item = index.internalPointer()
- return str(item.data(index.column()))
+ value = str(item.data(index.column()))
+ if role == Qt.DisplayRole:
+ return _(value) if self.should_translate_value(index, value) else value
+ return value
if role == Qt.ForegroundRole:
col_name = self.HEADERS[index.column()]
@@ -352,10 +422,15 @@ def data(self, index, role: int = Qt.DisplayRole):
style_item.brushes.get(col_name, style_item.brushes.get("default"))
)
+ def should_translate_value(self, index: QModelIndex, value: str) -> bool:
+ """Return whether a tree cell contains fixed interface text."""
+
+ return value in self.TRANSLATABLE_VALUES
+
def headerData(self, column, orientation, role: int = Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
try:
- return self.HEADERS[column]
+ return _(str(self.HEADERS[column]))
except IndexError:
pass
return None
diff --git a/activity_browser/ui/tables/models/impact_categories.py b/activity_browser/ui/tables/models/impact_categories.py
index 0753303fc..033a32004 100644
--- a/activity_browser/ui/tables/models/impact_categories.py
+++ b/activity_browser/ui/tables/models/impact_categories.py
@@ -44,7 +44,7 @@ def sync(self, query=None) -> None:
)
self.method_col = self._dataframe.columns.get_loc("method")
self.filterable_columns = {
- col: i for i, col in enumerate(self.HEADERS) if i is not self.method_col
+ col: i for i, col in enumerate(self.HEADERS) if i != self.method_col
}
self.updated.emit()
diff --git a/activity_browser/ui/tables/models/inventory.py b/activity_browser/ui/tables/models/inventory.py
index 6624ee7ff..6cf3a02ee 100644
--- a/activity_browser/ui/tables/models/inventory.py
+++ b/activity_browser/ui/tables/models/inventory.py
@@ -59,6 +59,8 @@ def sync(self):
class ActivitiesBiosphereListModel(DragPandasModel):
+ TRANSLATABLE_HEADERS = tuple(bc.AB_names_to_bw_keys)
+
def __init__(self, parent=None):
super().__init__(parent=parent)
self.act_fields = lambda: AB_metadata.get_existing_fields(
@@ -217,6 +219,7 @@ class ActivitiesBiosphereTreeModel(BaseTreeModel):
"ISIC rev.4 ecoinvent",
"key",
]
+ TRANSLATABLE_VALUES = ("No classification",)
def __init__(self, parent=None, database_name=None):
super().__init__(parent)
@@ -238,6 +241,16 @@ def flags(self, index):
res = super().flags(index) | Qt.ItemIsDragEnabled
return res
+ def should_translate_value(self, index, value: str) -> bool:
+ # "No classification" is a built-in branch label. An activity or
+ # product with exactly that name remains untouched at leaf level.
+ item = index.internalPointer()
+ return (
+ index.column() == 0
+ and item.childCount() > 0
+ and value in self.TRANSLATABLE_VALUES
+ )
+
def get_isic_tree(self) -> Tuple[dict, dict, dict]:
"""Generate an entry for every class of the ISIC and store its path.
diff --git a/activity_browser/ui/tables/models/lca_results.py b/activity_browser/ui/tables/models/lca_results.py
index a4e8a46b2..68918e572 100644
--- a/activity_browser/ui/tables/models/lca_results.py
+++ b/activity_browser/ui/tables/models/lca_results.py
@@ -1,18 +1,129 @@
# -*- coding: utf-8 -*-
import numpy as np
+import pandas as pd
+from PySide2.QtCore import Qt
+
+from activity_browser.i18n import _
from .base import PandasModel
-class LCAResultsModel(PandasModel):
+RESULT_METADATA_HEADERS = (
+ "index",
+ "amount",
+ "unit",
+ "reference product",
+ "name",
+ "location",
+ "database",
+ "categories",
+ "type",
+ "code",
+)
+
+
+class ResultHeaderModel(PandasModel):
+ """Translate only metadata columns whose positions are program-defined."""
+
+ TRANSLATABLE_HEADERS = RESULT_METADATA_HEADERS
+
+ def should_translate_header(self, section, value) -> bool:
+ return section in getattr(self, "_translatable_header_sections", set())
+
+ def set_leading_metadata_headers(self, maximum=None):
+ """Record leading, non-numeric metadata columns by position.
+
+ Contribution and inventory dataframes append numeric scientific result
+ columns after their metadata. Position and dtype distinguish a built-in
+ ``name`` header from a result column with the same spelling.
+ """
+
+ sections = set()
+ columns = list(self._dataframe.columns)
+ limit = len(columns) if maximum is None else min(maximum, len(columns))
+ for section in range(limit):
+ value = columns[section]
+ if value not in self.TRANSLATABLE_HEADERS:
+ break
+ if pd.api.types.is_numeric_dtype(self._dataframe.iloc[:, section]):
+ break
+ sections.add(section)
+ self._translatable_header_sections = sections
+
+
+class LCAResultsModel(ResultHeaderModel):
+ OVERVIEW_CORE_HEADERS = (
+ "amount",
+ "unit",
+ "reference product",
+ "name",
+ "location",
+ "database",
+ )
+ OVERVIEW_PREFIXES = (
+ ("index",) + OVERVIEW_CORE_HEADERS,
+ ("level_0", "level_1") + OVERVIEW_CORE_HEADERS,
+ )
+
def sync(self, df):
self._dataframe = df.replace(np.nan, "", regex=True)
+ columns = tuple(self._dataframe.columns)
+ matched_prefix = next(
+ (
+ prefix
+ for prefix in self.OVERVIEW_PREFIXES
+ if columns[: len(prefix)] == prefix
+ ),
+ None,
+ )
+ # The fixed text metadata fields must also be non-numeric. This keeps a
+ # scientific table with columns named ``amount``, ``name``, or
+ # ``database`` from accidentally looking like the overview schema.
+ if matched_prefix is not None:
+ amount_section = matched_prefix.index("amount")
+ text_sections = range(amount_section + 1, len(matched_prefix))
+ if any(
+ pd.api.types.is_numeric_dtype(self._dataframe.iloc[:, section])
+ for section in text_sections
+ ):
+ matched_prefix = None
+
+ self._translatable_header_sections = (
+ {
+ section
+ for section, value in enumerate(matched_prefix)
+ if value in self.TRANSLATABLE_HEADERS
+ }
+ if matched_prefix is not None
+ else set()
+ )
+ self._has_unnamed_index_headers = (
+ matched_prefix is not None
+ and matched_prefix[:2] == ("level_0", "level_1")
+ )
self.updated.emit()
+ def headerData(self, section, orientation, role=Qt.DisplayRole):
+ if orientation == Qt.Horizontal and role == Qt.DisplayRole:
+ value = self._dataframe.columns[section]
+ if getattr(self, "_has_unnamed_index_headers", False):
+ # ``reset_index`` gives unnamed MultiIndex levels these
+ # implementation names. Keep the dataframe/export untouched,
+ # but present the two fixed overview fields meaningfully.
+ if section == 0 and value == "level_0":
+ return _("database")
+ if section == 1 and value == "level_1":
+ return _("code")
+ return super().headerData(section, orientation, role)
+
+
+class InventoryModel(ResultHeaderModel):
-class InventoryModel(PandasModel):
def sync(self, df):
self._dataframe = df
+ # Inventory builders always place five metadata fields before the
+ # dynamically named reference-flow result columns.
+ self.set_leading_metadata_headers(maximum=5)
# set the visible columns
self.filterable_columns = {
col: i for i, col in enumerate(self._dataframe.columns.to_list())
@@ -26,13 +137,74 @@ def sync(self, df):
self.updated.emit()
-class ContributionModel(PandasModel):
- def sync(self, df, unit="relative share"):
+class ContributionModel(ResultHeaderModel):
+ RESULT_LABELS = (
+ "Score",
+ "Total",
+ "Rest (+)",
+ "Rest (-)",
+ )
+ DISPLAY_UNITS = (
+ "relative share",
+ "units of each impact category",
+ )
+ TRANSLATABLE_VALUES = RESULT_LABELS + DISPLAY_UNITS
- if "unit" in df.columns:
- # overwrite the unit col with 'relative share' if looking at relative results (except 3 'total' and 'rest' rows)
- df["unit"] = [""] * 3 + [unit] * (len(df) - 3)
+ def sync(self, df, unit="relative share", translate_unit=False):
+ df = df.copy()
+ self._translate_unit = translate_unit
+ prefix = (
+ tuple(str(value) for value in df["index"].iloc[:3])
+ if "index" in df
+ else ()
+ )
+ has_fixed_prefix = prefix in {
+ ("Score", "Rest (+)", "Rest (-)"),
+ ("Total", "Rest (+)", "Rest (-)"),
+ }
+
+ # Update only the object-valued metadata unit column. A numeric result
+ # column with the same name is scientific data and must stay untouched.
+ unit_sections = [
+ section
+ for section, value in enumerate(df.columns)
+ if value == "unit"
+ and not pd.api.types.is_numeric_dtype(df.iloc[:, section])
+ ]
+ if unit_sections:
+ fixed_prefix_rows = 3 if has_fixed_prefix else 0
+ df.iloc[:, unit_sections[0]] = [""] * fixed_prefix_rows + [unit] * (
+ len(df) - fixed_prefix_rows
+ )
- # drop any rows where all numbers are 0
- self._dataframe = df.loc[~(df.select_dtypes(include=np.number) == 0).all(axis=1)]
+ # Drop any rows where all numbers are 0. Remember which retained rows
+ # came from the three built-in result rows so user data is never
+ # translated merely because its spelling matches a fixed label.
+ keep = ~(df.select_dtypes(include=np.number) == 0).all(axis=1)
+ fixed_rows = (
+ np.arange(len(df)) < min(3, len(df))
+ if has_fixed_prefix
+ else np.zeros(len(df), dtype=bool)
+ )
+ self._fixed_result_rows = {
+ position
+ for position, is_fixed in enumerate(fixed_rows[keep.to_numpy()])
+ if is_fixed
+ }
+ self._dataframe = df.iloc[keep.to_numpy()]
+ self.set_leading_metadata_headers()
self.updated.emit()
+
+ def should_translate_value(self, index, value: str) -> bool:
+ column = self._dataframe.columns[index.column()]
+ if column == "index":
+ return (
+ index.row() in getattr(self, "_fixed_result_rows", set())
+ and value in self.RESULT_LABELS
+ )
+ if column == "unit":
+ return (
+ getattr(self, "_translate_unit", False)
+ and value in self.DISPLAY_UNITS
+ )
+ return False
diff --git a/activity_browser/ui/tables/models/lca_setup.py b/activity_browser/ui/tables/models/lca_setup.py
index 90f089f45..516903e1d 100644
--- a/activity_browser/ui/tables/models/lca_setup.py
+++ b/activity_browser/ui/tables/models/lca_setup.py
@@ -8,6 +8,7 @@
from activity_browser import signals, application
from activity_browser.bwutils import commontasks as bc
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from activity_browser.mod.bw2data.backends import ActivityDataset
@@ -101,6 +102,23 @@ def get_key(self, proxy: QModelIndex) -> tuple:
idx = self.proxy_to_source(proxy)
return self._dataframe.iat[idx.row(), self.key_col]
+ def data(self, index, role=Qt.DisplayRole):
+ """Localize only the program-generated missing-activity marker."""
+
+ if (
+ index.isValid()
+ and role in (Qt.DisplayRole, Qt.ToolTipRole)
+ and self._dataframe.columns[index.column()] == "Activity"
+ ):
+ key = self._dataframe.iat[index.row(), self.key_col]
+ try:
+ missing = key not in self._activities
+ except TypeError:
+ missing = True
+ if missing:
+ return _("NOT FOUND: {value}", value=key)
+ return super().data(index, role)
+
def load(self, cs_name: str = None):
for act in self._activities.values():
act.changed.disconnect(self.sync)
@@ -207,6 +225,20 @@ def get_method(self, proxy: Union[QModelIndex, int]) -> tuple:
idx = self.proxy_to_source(proxy)
return self._dataframe["method"][idx.row()]
+ def data(self, index, role=Qt.DisplayRole):
+ """Localize only the program-generated missing-method marker."""
+
+ if (
+ index.isValid()
+ and role in (Qt.DisplayRole, Qt.ToolTipRole)
+ and self._dataframe.columns[index.column()] == "Name"
+ ):
+ method_col = self._dataframe.columns.get_loc("method")
+ method = self._dataframe.iat[index.row(), method_col]
+ if method not in self._methods:
+ return _("NOT FOUND: {value}", value=method)
+ return super().data(index, role)
+
def load(self, cs_name: str = None) -> None:
"""
Load a calculation setup defined by cs_name into the methods table.
diff --git a/activity_browser/ui/tables/models/parameters.py b/activity_browser/ui/tables/models/parameters.py
index 41b3ed3cf..23724bab0 100644
--- a/activity_browser/ui/tables/models/parameters.py
+++ b/activity_browser/ui/tables/models/parameters.py
@@ -10,6 +10,7 @@
from PySide2.QtCore import QModelIndex, Slot
from activity_browser import actions, application
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from activity_browser.mod.bw2data.parameters import (ActivityParameter,
DatabaseParameter, Group,
@@ -88,7 +89,7 @@ def edit_single_parameter(self, index: QModelIndex) -> None:
except Exception as e:
QtWidgets.QMessageBox.warning(
application.main_window,
- "Could not save changes",
+ _("Could not save changes"),
str(e),
QtWidgets.QMessageBox.Ok,
QtWidgets.QMessageBox.Ok,
diff --git a/activity_browser/ui/tables/models/scenarios.py b/activity_browser/ui/tables/models/scenarios.py
index ff733d747..1d562c974 100644
--- a/activity_browser/ui/tables/models/scenarios.py
+++ b/activity_browser/ui/tables/models/scenarios.py
@@ -11,6 +11,10 @@
from .base import PandasModel
+class TooManyParametersError(ValueError):
+ """A scenario file contains more parameter rows than the project."""
+
+
class ScenarioModel(PandasModel):
HEADERS = ["Name", "Group", "default"]
MATCH_COLS = ["Name", "Group"]
@@ -45,9 +49,8 @@ def sync(self, df: pd.DataFrame = None, include_default: bool = True) -> None:
else:
# Now we're gonna need to ensure that the dataframe is of
# the same size
- assert (
- len(data) >= df.shape[0]
- ), "Too many parameters found, not possible."
+ if len(data) < df.shape[0]:
+ raise TooManyParametersError
missing = len(data) - df.shape[0]
if missing != 0:
nan_data = pd.DataFrame(
diff --git a/activity_browser/ui/tables/parameters.py b/activity_browser/ui/tables/parameters.py
index 85f604c85..adb044331 100644
--- a/activity_browser/ui/tables/parameters.py
+++ b/activity_browser/ui/tables/parameters.py
@@ -5,6 +5,7 @@
from PySide2.QtWidgets import QAction, QMenu, QMessageBox
from activity_browser import actions, project_settings, signals
+from activity_browser.i18n import _
from ..icons import qicons
from .delegates import *
@@ -30,20 +31,20 @@ def __init__(self, parent=None):
self.doubleClicked.connect(
lambda: self.model.handle_double_click(self.currentIndex())
)
- self.delete_action = QAction(qicons.delete, "Delete parameter", None)
+ self.delete_action = QAction(qicons.delete, _("Delete parameter"), None)
self.delete_action.triggered.connect(
lambda: self.model.delete_parameter(self.currentIndex())
)
- self.rename_action = QAction(qicons.edit, "Rename parameter", None)
+ self.rename_action = QAction(qicons.edit, _("Rename parameter"), None)
self.rename_action.triggered.connect(
lambda: self.model.handle_parameter_rename(self.currentIndex())
)
self.modify_uncertainty_action = QAction(
- qicons.edit, "Modify uncertainty", None
+ qicons.edit, _("Modify uncertainty"), None
)
self.modify_uncertainty_action.triggered.connect(self.modify_uncertainty)
self.remove_uncertainty_action = QAction(
- qicons.delete, "Remove uncertainty", None
+ qicons.delete, _("Remove uncertainty"), None
)
self.remove_uncertainty_action.triggered.connect(self.remove_uncertainty)
self.model.updated.connect(self.update_proxy_model)
@@ -196,8 +197,8 @@ def dropEvent(self, event: QDropEvent) -> None:
):
QMessageBox.warning(
self,
- "Not allowed",
- "Cannot set activity parameters on read-only databases",
+ _("Not allowed"),
+ _("Cannot set activity parameters on read-only databases"),
QMessageBox.Ok,
QMessageBox.Ok,
)
@@ -218,7 +219,7 @@ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
if self.indexAt(event.pos()).row() == -1:
return
menu = QMenu(self)
- menu.addAction(qicons.add, "Open activities", self.open_activity_tab)
+ menu.addAction(qicons.add, _("Open activities"), self.open_activity_tab)
menu.addAction(self.rename_action)
menu.addAction(self.delete_action)
menu.addAction(self.modify_uncertainty_action)
diff --git a/activity_browser/ui/tables/plugins.py b/activity_browser/ui/tables/plugins.py
index 925a43358..b453fcb32 100644
--- a/activity_browser/ui/tables/plugins.py
+++ b/activity_browser/ui/tables/plugins.py
@@ -3,6 +3,8 @@
from PySide2 import QtCore, QtWidgets
from PySide2.QtWidgets import QMessageBox
+from activity_browser.i18n import _
+
from ...signals import signals
from .delegates import CheckboxDelegate
from .models.plugins import PluginsModel
@@ -41,9 +43,9 @@ def mousePressEvent(self, e):
# plugin_name = self.model.get_plugin_name(proxy)
if not new_value:
msgBox = QMessageBox()
- msgBox.setText("Remove plugin from project ?")
+ msgBox.setText(_("Remove plugin from project?"))
msgBox.setInformativeText(
- "This will remove all data created by the plugin."
+ _("This will remove all data created by the plugin.")
)
msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
msgBox.setDefaultButton(QMessageBox.Cancel)
diff --git a/activity_browser/ui/tables/views.py b/activity_browser/ui/tables/views.py
index 363d43127..02a6cd681 100644
--- a/activity_browser/ui/tables/views.py
+++ b/activity_browser/ui/tables/views.py
@@ -7,17 +7,82 @@
from PySide2.QtWidgets import QApplication, QSizePolicy, QTableView
from activity_browser import ab_settings
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from ..icons import qicons
from ..widgets.dialog import FilterManagerDialog, SimpleFilterDialog
from .delegates import ViewOnlyDelegate
from .models import PandasModel
-from .models.base import ABSortProxyModel
+from .models.base import ABSortProxyModel, FilterMode, FilterOperator
log = getLogger(__name__)
+STRING_FILTERS = (
+ (
+ FilterOperator.CONTAINS,
+ _("contains"),
+ _("values in the column contain"),
+ ),
+ (
+ FilterOperator.NOT_CONTAINS,
+ _("does not contain"),
+ _("values in the column do not contain"),
+ ),
+ (FilterOperator.EQUALS, _("equals"), _("values in the column equal")),
+ (
+ FilterOperator.NOT_EQUALS,
+ _("does not equal"),
+ _("values in the column do not equal"),
+ ),
+ (
+ FilterOperator.STARTS_WITH,
+ _("starts with"),
+ _("values in the column start with"),
+ ),
+ (
+ FilterOperator.NOT_STARTS_WITH,
+ _("does not start with"),
+ _("values in the column do not start with"),
+ ),
+ (
+ FilterOperator.ENDS_WITH,
+ _("ends with"),
+ _("values in the column end with"),
+ ),
+ (
+ FilterOperator.NOT_ENDS_WITH,
+ _("does not end with"),
+ _("values in the column do not end with"),
+ ),
+)
+
+NUMERIC_FILTERS = (
+ (
+ FilterOperator.NUM_EQUALS,
+ "=",
+ _("values in the column equal"),
+ ),
+ (
+ FilterOperator.NUM_NOT_EQUALS,
+ "!=",
+ _("values in the column do not equal"),
+ ),
+ (
+ FilterOperator.GREATER_THAN_OR_EQUAL,
+ ">=",
+ _("values in the column are greater than or equal to"),
+ ),
+ (
+ FilterOperator.LESS_THAN_OR_EQUAL,
+ "<=",
+ _("values in the column are smaller than or equal to"),
+ ),
+ (FilterOperator.BETWEEN, "<= x <=", _("values in the column are between")),
+)
+
+
class ABDataFrameView(QtWidgets.QTableView):
"""Base class for showing pandas dataframe objects as tables."""
@@ -79,12 +144,12 @@ def savefilepath(
Uses the application directory for AB
"""
safe_name = bd.utils.safe_filename(default_file_name, add_hash=False)
- caption = caption or "Choose location to save lca results"
- filepath, _ = QtWidgets.QFileDialog.getSaveFileName(
+ caption = caption or _("Choose location to save LCA results")
+ filepath, _selected_filter = QtWidgets.QFileDialog.getSaveFileName(
parent=self,
caption=caption,
dir=os.path.join(ab_settings.data_dir, safe_name),
- filter=file_filter or self.ALL_FILTER,
+ filter=_(file_filter or self.ALL_FILTER),
)
# getSaveFileName can now weirdly return Path objects.
return str(filepath) if filepath else filepath
@@ -150,34 +215,16 @@ class ABFilterableDataFrameView(ABDataFrameView):
"""
FILTER_TYPES = {
- "str": [
- "contains",
- "does not contain",
- "equals",
- "does not equal",
- "starts with",
- "does not start with",
- "ends with",
- "does not end with",
- ],
- "str_tt": [
- "values in the column contain",
- "values in the column do not contain",
- "values in the column equal",
- "values in the column do not equal",
- "values in the column start with",
- "values in the column do not start with",
- "values in the column end with",
- "values in the column do not end with",
- ],
- "num": ["=", "!=", ">=", "<=", "<= x <="],
- "num_tt": [
- "values in the column equal",
- "values in the column do not equal",
- "values in the column are greater than or equal to",
- "values in the column are smaller than or equal to",
- "values in the column are between",
- ],
+ "str": [label for _, label, _tooltip in STRING_FILTERS],
+ "str_ids": [operator for operator, _label, _tooltip in STRING_FILTERS],
+ "str_tt": [tooltip for _operator, _label, tooltip in STRING_FILTERS],
+ "num": [label for _, label, _tooltip in NUMERIC_FILTERS],
+ "num_ids": [operator for operator, _label, _tooltip in NUMERIC_FILTERS],
+ "num_tt": [tooltip for _operator, _label, tooltip in NUMERIC_FILTERS],
+ }
+ FILTER_LABELS = {
+ operator: label
+ for operator, label, _tooltip in STRING_FILTERS + NUMERIC_FILTERS
}
def __init__(self, parent=None):
@@ -229,14 +276,16 @@ def header_context_menu(self) -> None:
quick_filter_widget = QtWidgets.QWidget()
quick_filter_widget.setLayout(quick_filter_layout)
quick_filter_widget.setToolTip(
- "Filter this column on the input,\n"
- "press 'enter' or the search button to filter"
+ _(
+ "Filter this column on the input,\n"
+ "press 'enter' or the search button to filter"
+ )
)
# write previous filter to the quick-filter input if we have one
if prev_filter := self.prev_quick_filter.get(self.selected_column, False):
self.input_line.setText(prev_filter[1])
else:
- self.input_line.setPlaceholderText("Quick filter ...")
+ self.input_line.setPlaceholderText(_("Quick filter ..."))
self.input_line.textChanged.connect(self.debounce_quick_filter.start)
self.input_line.returnPressed.connect(menu.close)
QAline = QtWidgets.QWidgetAction(self)
@@ -247,10 +296,11 @@ def header_context_menu(self) -> None:
mf_menu = QtWidgets.QMenu(menu)
mf_menu.setToolTipsVisible(True)
mf_menu.setIcon(qicons.filter)
- mf_menu.setTitle("More filters")
+ mf_menu.setTitle(_("More filters"))
filter_actions = []
- for i, f in enumerate(self.FILTER_TYPES[col_type]):
- fa = QtWidgets.QAction(text=f)
+ for i, filter_id in enumerate(self.FILTER_TYPES[col_type + "_ids"]):
+ fa = QtWidgets.QAction(text=self.FILTER_TYPES[col_type][i])
+ fa.setData(filter_id)
fa.setToolTip(self.FILTER_TYPES[col_type + "_tt"][i])
fa.triggered.connect(self.simple_filter_dialog)
filter_actions.append(fa)
@@ -258,14 +308,14 @@ def header_context_menu(self) -> None:
mf_menu.addAction(fa)
menu.addMenu(mf_menu)
# edit filters main menu
- filter_man = QtWidgets.QAction(qicons.edit, "Manage filters")
+ filter_man = QtWidgets.QAction(qicons.edit, _("Manage filters"))
filter_man.triggered.connect(self.filter_manager_dialog)
- filter_man.setToolTip("Open the filter management menu")
+ filter_man.setToolTip(_("Open the filter management menu"))
menu.addAction(filter_man)
# delete column filters option
- col_del = QtWidgets.QAction(qicons.delete, "Remove column filters")
+ col_del = QtWidgets.QAction(qicons.delete, _("Remove column filters"))
col_del.triggered.connect(self.reset_column_filters)
- col_del.setToolTip("Remove all filters on this column")
+ col_del.setToolTip(_("Remove all filters on this column"))
menu.addAction(col_del)
col_del.setEnabled(False)
if isinstance(self.filters, dict) and self.filters.get(
@@ -273,9 +323,9 @@ def header_context_menu(self) -> None:
):
col_del.setEnabled(True)
# delete all filters option
- all_del = QtWidgets.QAction(qicons.delete, "Remove all filters")
+ all_del = QtWidgets.QAction(qicons.delete, _("Remove all filters"))
all_del.triggered.connect(self.reset_filters)
- all_del.setToolTip("Remove all filters in this table")
+ all_del.setToolTip(_("Remove all filters in this table"))
menu.addAction(all_del)
all_del.setEnabled(False)
if isinstance(self.filters, dict):
@@ -287,17 +337,19 @@ def header_context_menu(self) -> None:
):
menu.addSeparator()
active_filters_label = QtWidgets.QAction(
- qicons.filter, "Active column filters:"
+ qicons.filter, _("Active column filters:")
)
active_filters_label.setEnabled(False)
menu.addAction(active_filters_label)
active_filters = []
for filter_data in self.filters[self.selected_column]["filters"]:
- if filter_data[0] == "<= x <=":
- q = " and ".join(filter_data[1])
+ if filter_data[0] == FilterOperator.BETWEEN:
+ q = _(" and ").join(filter_data[1])
else:
q = filter_data[1]
- filter_str = ": ".join([filter_data[0], q])
+ filter_str = ": ".join(
+ [self.FILTER_LABELS.get(filter_data[0], filter_data[0]), q]
+ )
f = QtWidgets.QAction(text=filter_str)
f.setEnabled(False)
active_filters.append(f)
@@ -327,10 +379,10 @@ def quick_filter(self) -> None:
]
if self.model.different_column_types.get(col_name):
# column is type 'num'
- filt = ("=", query)
+ filt = (FilterOperator.NUM_EQUALS, query)
else:
# column is type 'str'
- filt = ("contains", query, False)
+ filt = (FilterOperator.CONTAINS, query, False)
# check if quick filter exists for this col, if so; remove from self.filters
if prev_filter := self.prev_quick_filter.get(self.selected_column, False):
self.filters[self.selected_column]["filters"].remove(prev_filter)
@@ -353,6 +405,10 @@ def quick_filter(self) -> None:
def filter_manager_dialog(self) -> None:
# get right data
column_names = self.model.filterable_columns
+ column_labels = {
+ col_id: self.model.headerData(col_id, Qt.Horizontal, Qt.DisplayRole)
+ for col_id in column_names.values()
+ }
# show dialog
dialog = FilterManagerDialog(
@@ -361,6 +417,7 @@ def filter_manager_dialog(self) -> None:
filter_types=self.FILTER_TYPES,
selected_column=self.selected_column,
column_types=self.model.different_column_types,
+ column_labels=column_labels,
)
if dialog.exec_() == FilterManagerDialog.Accepted:
# set the filters
@@ -380,14 +437,17 @@ def filter_manager_dialog(self) -> None:
self.apply_filters()
def simple_filter_dialog(self, preset_type: str = None) -> None:
- if not preset_type:
- preset_type = self.sender().text()
+ if not preset_type or isinstance(preset_type, bool):
+ preset_type = self.sender().data()
# get right data
column_name = {v: k for k, v in self.model.filterable_columns.items()}[
self.selected_column
]
col_type = self.model.different_column_types.get(column_name, "str")
+ column_label = self.model.headerData(
+ self.selected_column, Qt.Horizontal, Qt.DisplayRole
+ )
# show dialog
dialog = SimpleFilterDialog(
@@ -395,6 +455,7 @@ def simple_filter_dialog(self, preset_type: str = None) -> None:
filter_types=self.FILTER_TYPES,
column_type=col_type,
preset_type=preset_type,
+ column_label=column_label,
)
if dialog.exec_() == SimpleFilterDialog.Accepted:
new_filter = dialog.get_filter
@@ -416,7 +477,7 @@ def add_filter(self, new_filter: tuple) -> None:
and len(all_filters[self.selected_column]["filters"]) > 1
):
# a mode does not exist, but there are multiple filters
- all_filters[self.selected_column]["mode"] = "OR"
+ all_filters[self.selected_column]["mode"] = FilterMode.OR
else:
# filters don't yet exist for this column:
all_filters[self.selected_column] = {"filters": [new_filter]}
@@ -424,7 +485,7 @@ def add_filter(self, new_filter: tuple) -> None:
# no filters exist
all_filters = {
self.selected_column: {"filters": [new_filter]},
- "mode": "AND",
+ "mode": FilterMode.AND,
}
self.write_filters(all_filters)
diff --git a/activity_browser/ui/threading.py b/activity_browser/ui/threading.py
index 191bc1876..3e3070b42 100644
--- a/activity_browser/ui/threading.py
+++ b/activity_browser/ui/threading.py
@@ -63,7 +63,7 @@ def filter(self, record: logging.LogRecord) -> bool:
def emit(self, record: logging.LogRecord):
try:
- thread_local.progress_slot(None, record.message)
+ thread_local.progress_slot(None, record.getMessage())
except AttributeError:
pass
diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py
index 9d1ca6c70..ce7dd4ace 100644
--- a/activity_browser/ui/web/base.py
+++ b/activity_browser/ui/web/base.py
@@ -1,3 +1,4 @@
+import html
import json
import os
from abc import abstractmethod
@@ -9,6 +10,7 @@
from PySide2.QtCore import QObject, Qt, QUrl, Signal, Slot
from activity_browser import ab_settings, signals
+from activity_browser.i18n import _, current_language
from activity_browser.mod import bw2data as bd
from ... import utils
@@ -19,10 +21,9 @@
class BaseNavigatorWidget(QtWidgets.QWidget):
- HELP_TEXT = """
- This is the text shown when the user presses 'help'.
- """
+ HELP_TEXT = ("This help text describes how to use the graph.",)
HTML_FILE = ""
+ PAGE_TITLE = "Graph"
def __init__(self, parent=None, css_file: str = "", *args, **kwargs):
super().__init__(parent)
@@ -39,15 +40,25 @@ def __init__(self, parent=None, css_file: str = "", *args, **kwargs):
self.view.setContextMenuPolicy(Qt.PreventContextMenu)
self.view.page().setWebChannel(self.channel)
self.url = QUrl.fromLocalFile(self.HTML_FILE)
+ self.html_base_url = QUrl.fromLocalFile(
+ os.path.dirname(self.HTML_FILE) + os.path.sep
+ )
self.css_file = css_file
# Various Qt objects
- self.label_help = QtWidgets.QLabel(self.HELP_TEXT)
- self.button_toggle_help = QtWidgets.QPushButton("Help")
+ help_text = (
+ _(self.HELP_TEXT)
+ if isinstance(self.HELP_TEXT, str)
+ else "\n".join(_(line) for line in self.HELP_TEXT)
+ )
+ self.label_help = QtWidgets.QLabel(help_text)
+ self.button_toggle_help = QtWidgets.QPushButton(_("Help"))
self.button_back = QtWidgets.QPushButton(qicons.backward, "")
+ self.button_back.setToolTip(_("Back"))
self.button_forward = QtWidgets.QPushButton(qicons.forward, "")
- self.button_refresh = QtWidgets.QPushButton("Refresh HTML")
- self.button_random_activity = QtWidgets.QPushButton("Random Activity")
+ self.button_forward.setToolTip(_("Forward"))
+ self.button_refresh = QtWidgets.QPushButton(_("Refresh HTML"))
+ self.button_random_activity = QtWidgets.QPushButton(_("Random Activity"))
def load_finished_handler(self, *args, **kwargs) -> None:
"""Executed when webpage has been loaded for the first time or refreshed.
@@ -74,17 +85,17 @@ def toggle_help(self) -> None:
def go_forward(self) -> None:
if self.graph.forward():
- signals.new_statusbar_message.emit("Going forward.")
+ signals.new_statusbar_message.emit(_("Going forward."))
self.send_json()
else:
- signals.new_statusbar_message.emit("No data to go forward to.")
+ signals.new_statusbar_message.emit(_("No data to go forward to."))
def go_back(self) -> None:
if self.graph.back():
- signals.new_statusbar_message.emit("Going back.")
+ signals.new_statusbar_message.emit(_("Going back."))
self.send_json()
else:
- signals.new_statusbar_message.emit("No data to go back to.")
+ signals.new_statusbar_message.emit(_("No data to go back to."))
def send_json(self) -> None:
self.bridge.graph_ready.emit(self.graph.json_data)
@@ -94,7 +105,29 @@ def send_json(self) -> None:
self.bridge.style.emit(style_element)
def draw_graph(self) -> None:
- self.view.load(self.url)
+ self.view.setHtml(self.render_html(), self.html_base_url)
+
+ def render_html(self) -> str:
+ """Render the graph page with fixed UI text in the active language."""
+
+ source = utils.read_file_text(self.HTML_FILE)
+ translations = {
+ "individual_impact": _("Individual impact"),
+ "cumulative_impact": _("Cumulative impact"),
+ }
+ replacements = {
+ "LANGUAGE": current_language().replace("_", "-"),
+ "PAGE_TITLE": _(self.PAGE_TITLE),
+ "RESET_ZOOM": _("Reset Zoom"),
+ "DOWNLOAD_SVG": _("Download SVG"),
+ }
+ for name, value in replacements.items():
+ source = source.replace(f"{{{{{name}}}}}", html.escape(value))
+
+ translations_json = json.dumps(translations, ensure_ascii=False).replace(
+ "", "<\\/"
+ )
+ return source.replace("{{TRANSLATIONS_JSON}}", translations_json)
@abstractmethod
def random_graph(self) -> None:
@@ -104,14 +137,14 @@ def random_graph(self) -> None:
ALL_FILTER = "All Files (*.*)"
-def savefilepath(default_file_name: str, file_filter: str = ALL_FILTER):
- default = default_file_name or "Graph SVG Export"
+def savefilepath(default_file_name: str, file_filter: str = None):
+ default = default_file_name or _("Graph SVG Export")
safe_name = bd.utils.safe_filename(default, add_hash=False)
- filepath, _ = QtWidgets.QFileDialog.getSaveFileName(
- caption="Choose location to save svg",
+ filepath = QtWidgets.QFileDialog.getSaveFileName(
+ caption=_("Choose location to save SVG"),
dir=os.path.join(ab_settings.data_dir, safe_name),
- filter=file_filter,
- )
+ filter=file_filter or _("All Files (*.*)"),
+ )[0]
return filepath
diff --git a/activity_browser/ui/web/navigator.py b/activity_browser/ui/web/navigator.py
index 822256ec6..8d5ada0c1 100644
--- a/activity_browser/ui/web/navigator.py
+++ b/activity_browser/ui/web/navigator.py
@@ -10,6 +10,7 @@
from PySide2.QtCore import Slot
from activity_browser import signals
+from activity_browser.i18n import _
from activity_browser.mod.bw2data import Database, get_activity, databases, Edge
from activity_browser.mod.bw2data.backends import ExchangeDataset, ActivityDataset
@@ -32,32 +33,38 @@
class GraphNavigatorWidget(BaseNavigatorWidget):
- HELP_TEXT = """
- How to use the Graph Navigator:
-
- EXPANSION MODE (DEFAULT):
- Click on activities to expand graph.
- - click: expand upwards
- - click + shift: expand downstream
- - click + alt: delete activity
-
- Checkbox "Add only direct up-/downstream exchanges" - there are two ways to expand the graph:
- 1) adding direct up-/downstream nodes and connections (DEFAULT).
- 2) adding direct up-/downstream nodes and connections AS WELL as ALL OTHER connections between the activities in the graph.
- The first option results in cleaner (but not complete) graphs.
-
- Checkbox "Remove orphaned nodes": by default nodes that do not link to the central activity (see title) are removed (this may happen after deleting nodes). Uncheck to disable.
-
- Checkbox "Flip negative flows" (experimental): Arrows of negative product flows (e.g. from ecoinvent treatment activities or from substitution) can be flipped.
- The resulting representation can be more intuitive for understanding the physical product flows (e.g. that wastes are outputs of activities and not negative inputs).
-
-
- NAVIGATION MODE:
- Click on activities to jump to specific activities (instead of expanding the graph).
- """
+ HELP_TEXT = (
+ "How to use the Graph Navigator:",
+ "",
+ "EXPANSION MODE (DEFAULT):",
+ "Click an activity to expand the graph.",
+ "- click: expand upstream",
+ "- shift + click: expand downstream",
+ "- alt + click: remove the activity",
+ "",
+ '"Add only direct up-/downstream exchanges" offers two expansion options:',
+ "1) Add only the selected direct upstream/downstream nodes and connections (default).",
+ "2) Also add every other connection between activities already in the graph.",
+ "The first option produces a cleaner, but incomplete, graph.",
+ "",
+ (
+ '"Remove orphaned nodes" removes nodes that no longer connect to the '
+ "central activity after a node is deleted. Clear the checkbox to keep them."
+ ),
+ "",
+ (
+ '"Flip negative flows" (experimental) reverses arrows for negative '
+ "product flows, such as treatment or substitution flows. This can make "
+ "physical product flows easier to understand."
+ ),
+ "",
+ "NAVIGATION MODE:",
+ "Click an activity to make it the new central activity instead of expanding the graph.",
+ )
HTML_FILE = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "../../static/navigator.html"
)
+ PAGE_TITLE = "Graph Navigator"
def __init__(self, parent=None, key=None):
super().__init__(parent, css_file="navigator.css")
@@ -68,19 +75,21 @@ def __init__(self, parent=None, key=None):
self.graph = Graph()
# default settings
- self.navigation_label = itertools.cycle(
- ["Current mode: Expansion", "Current mode: Navigation"]
- )
+ self._expansion_mode = True
self.selected_db = None
- self.button_navigation_mode = QtWidgets.QPushButton(next(self.navigation_label))
+ self.button_navigation_mode = QtWidgets.QPushButton(
+ _("Current mode: Expansion")
+ )
self.checkbox_direct_only = QtWidgets.QCheckBox(
- "Add only direct up-/downstream exchanges"
+ _("Add only direct up-/downstream exchanges")
)
self.checkbox_remove_orphaned_nodes = QtWidgets.QCheckBox(
- "Remove orphaned nodes"
+ _("Remove orphaned nodes")
+ )
+ self.checkbox_flip_negative_edges = QtWidgets.QCheckBox(
+ _("Flip negative flows")
)
- self.checkbox_flip_negative_edges = QtWidgets.QCheckBox("Flip negative flows")
self.layout = QtWidgets.QVBoxLayout()
# Prepare graph
@@ -136,19 +145,28 @@ def construct_layout(self) -> None:
# checkbox all_exchanges_in_graph
self.checkbox_direct_only.setChecked(True)
self.checkbox_direct_only.setToolTip(
- "When adding activities, show product flows between ALL activities or just selected up-/downstream flows"
+ _(
+ "When adding activities, show product flows between ALL "
+ "activities or just selected up-/downstream flows"
+ )
)
# checkbox remove orphaned nodes
self.checkbox_remove_orphaned_nodes.setChecked(True)
self.checkbox_remove_orphaned_nodes.setToolTip(
- "When removing activities, automatically remove those that have no further connection to the original product"
+ _(
+ "When removing activities, automatically remove those that "
+ "have no further connection to the original product"
+ )
)
# checkbox flip negative edges
self.checkbox_flip_negative_edges.setChecked(False)
self.checkbox_flip_negative_edges.setToolTip(
- "Flip negative product flows (e.g. from ecoinvent treatment activities or from substitution)"
+ _(
+ "Flip negative product flows (e.g. from ecoinvent treatment "
+ "activities or from substitution)"
+ )
)
# Controls Layout
hl_controls = QtWidgets.QHBoxLayout()
@@ -181,11 +199,16 @@ def update_graph_settings(self):
@property
def is_expansion_mode(self) -> bool:
- return "Expansion" in self.button_navigation_mode.text()
+ return self._expansion_mode
@Slot(name="toggleNavigationMode")
def toggle_navigation_mode(self):
- mode = next(self.navigation_label)
+ self._expansion_mode = not self._expansion_mode
+ mode = _(
+ "Current mode: Expansion"
+ if self._expansion_mode
+ else "Current mode: Navigation"
+ )
self.button_navigation_mode.setText(mode)
log.info(f"Switched to: {mode}")
self.checkbox_remove_orphaned_nodes.setVisible(self.is_expansion_mode)
@@ -198,7 +221,7 @@ def new_graph(self, key: tuple) -> None:
@Slot(name="reload_graph")
def reload_graph(self) -> None:
- signals.new_statusbar_message.emit("Reloading graph")
+ signals.new_statusbar_message.emit(_("Reloading graph"))
self.graph.update(delete_unstacked=False)
@Slot(object, name="update_graph")
@@ -244,7 +267,7 @@ def random_graph(self) -> None:
self.new_graph(Database(self.selected_db).random().key)
else:
QtWidgets.QMessageBox.information(
- None, "Not possible.", "Please load a database first."
+ None, _("Not possible."), _("Please load a database first.")
)
@@ -497,7 +520,9 @@ def build_json_edge(exc, flip_negative: bool) -> dict:
"amount": amount,
"unit": exc.get("unit"),
"product": reference,
- "tooltip": "{:.3g} {} of {}".format(
- amount, exc.get("unit", ""), reference
+ "tooltip": _("{amount:.3g} {unit} of {product}").format(
+ amount=amount,
+ unit=exc.get("unit", ""),
+ product=reference,
),
}
diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/ui/web/sankey_navigator.py
index e637ed3cc..d13734961 100644
--- a/activity_browser/ui/web/sankey_navigator.py
+++ b/activity_browser/ui/web/sankey_navigator.py
@@ -11,6 +11,7 @@
from PySide2.QtWidgets import QComboBox
from activity_browser import signals
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from activity_browser.mod.bw2data.backends import ActivityDataset
@@ -44,16 +45,16 @@
class SankeyNavigatorWidget(BaseNavigatorWidget):
- HELP_TEXT = """
- LCA Sankey:
-
- Red flows: Impacts
- Green flows: Avoided impacts
-
- """
+ HELP_TEXT = (
+ "LCA Sankey:",
+ "",
+ "Red flows: Impacts",
+ "Green flows: Avoided impacts",
+ )
HTML_FILE = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "../../static/sankey_navigator.html"
)
+ PAGE_TITLE = "Graph LCA"
def __init__(self, cs_name, parent=None):
super().__init__(parent, css_file="sankey_navigator.css")
@@ -70,13 +71,13 @@ def __init__(self, cs_name, parent=None):
self.graph = Graph()
# Additional Qt objects
- self.scenario_label = QtWidgets.QLabel("Scenario: ")
+ self.scenario_label = QtWidgets.QLabel(_("Scenario: "))
self.func_unit_cb = QtWidgets.QComboBox()
self.method_cb = QtWidgets.QComboBox()
self.scenario_cb = QtWidgets.QComboBox()
self.cutoff_sb = QtWidgets.QDoubleSpinBox()
self.max_calc_sb = QtWidgets.QDoubleSpinBox()
- self.button_calculate = QtWidgets.QPushButton("Calculate")
+ self.button_calculate = QtWidgets.QPushButton(_("Calculate"))
self.layout = QtWidgets.QVBoxLayout()
# graph
@@ -105,10 +106,10 @@ def construct_layout(self) -> None:
# Layout Reference Flows and Impact Categories
grid_lay = QtWidgets.QGridLayout()
- grid_lay.addWidget(QtWidgets.QLabel("Reference flow: "), 0, 0)
+ grid_lay.addWidget(QtWidgets.QLabel(_("Reference flow: ")), 0, 0)
grid_lay.addWidget(self.scenario_label, 1, 0)
- grid_lay.addWidget(QtWidgets.QLabel("Impact indicator: "), 2, 0)
+ grid_lay.addWidget(QtWidgets.QLabel(_("Impact indicator: ")), 2, 0)
self.update_calculation_setup()
@@ -117,7 +118,7 @@ def construct_layout(self) -> None:
grid_lay.addWidget(self.method_cb, 2, 1)
# cut-off
- grid_lay.addWidget(QtWidgets.QLabel("cutoff: "), 2, 2)
+ grid_lay.addWidget(QtWidgets.QLabel(_("Cutoff: ")), 2, 2)
self.cutoff_sb.setRange(0.0, 1.0)
self.cutoff_sb.setSingleStep(0.001)
self.cutoff_sb.setDecimals(4)
@@ -126,7 +127,7 @@ def construct_layout(self) -> None:
grid_lay.addWidget(self.cutoff_sb, 2, 3)
# max-iterations of graph traversal
- grid_lay.addWidget(QtWidgets.QLabel("Calculation depth: "), 2, 4)
+ grid_lay.addWidget(QtWidgets.QLabel(_("Calculation depth: ")), 2, 4)
self.max_calc_sb.setRange(1, 2000)
self.max_calc_sb.setSingleStep(50)
self.max_calc_sb.setDecimals(0)
@@ -284,7 +285,7 @@ def update_sankey(
del data["lca"]
except (ValueError, ZeroDivisionError) as e:
- QtWidgets.QMessageBox.information(None, "Not possible.", str(e))
+ QtWidgets.QMessageBox.information(None, _("Not possible."), str(e))
log.debug(
f"Completed graph traversal ({round(time.time() - start, 2)} seconds, {data['counter']} iterations)"
)
@@ -310,7 +311,7 @@ def random_graph(self) -> None:
self.update_sankey(demand, method)
else:
QtWidgets.QMessageBox.information(
- None, "Not possible.", "Please load a database first."
+ None, _("Not possible."), _("Please load a database first.")
)
@@ -363,17 +364,18 @@ def build_title(demand: tuple, lca_score: float, lcia_unit: str) -> str:
act, amount = demand[0], demand[1]
if type(act) is tuple or type(act) is int:
act = bd.get_activity(act)
- format_str = (
- "Reference flow: {:.2g} {} {} | {} | {}
" "Total impact: {:.2g} {}"
+ format_str = _(
+ "Reference flow: {amount:.2g} {unit} {product} | {activity} | "
+ "{location}
Total impact: {impact:.2g} {impact_unit}"
)
return format_str.format(
- amount,
- act.get("unit"),
- act.get("reference product") or act.get("name"),
- act.get("name"),
- act.get("location"),
- lca_score,
- lcia_unit,
+ amount=amount,
+ unit=act.get("unit"),
+ product=act.get("reference product") or act.get("name"),
+ activity=act.get("name"),
+ location=act.get("location"),
+ impact=lca_score,
+ impact_unit=lcia_unit,
)
@staticmethod
diff --git a/activity_browser/ui/web/webutils.py b/activity_browser/ui/web/webutils.py
index 072d4d040..a9dce2131 100644
--- a/activity_browser/ui/web/webutils.py
+++ b/activity_browser/ui/web/webutils.py
@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
import os
+from pathlib import Path
from PySide2 import QtCore, QtGui, QtWebEngineWidgets, QtWidgets
# type "localhost:3999" in Chrome for DevTools of AB web content
+from activity_browser.i18n import current_language
from activity_browser.utils import get_base_path
os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "3999"
@@ -37,6 +39,7 @@ def __init__(self, parent=None, url=None, html_file=None):
if html_file:
# print("Loading File:", html_file)
+ html_file = localized_html_path(html_file)
self.url = QtCore.QUrl.fromLocalFile(html_file)
self.page.allowed_pages.append(self.url)
self.page.load(self.url)
@@ -61,3 +64,17 @@ def get_static_js_path(file_name: str = "") -> str:
def get_static_css_path(file_name: str = "") -> str:
return str(get_base_path().joinpath("static", "css", file_name))
+
+
+def localized_html_path(html_file: str, language: str = None) -> str:
+ """Return a language-specific sibling HTML file when one exists.
+
+ For example, ``welcome.html`` resolves to ``welcome.zh_CN.html`` when the
+ current language is Simplified Chinese. Missing translations safely fall
+ back to the original file.
+ """
+
+ path = Path(html_file)
+ language = language or current_language()
+ localized = path.with_name(f"{path.stem}.{language}{path.suffix}")
+ return str(localized if localized.is_file() else path)
diff --git a/activity_browser/ui/widgets/activity.py b/activity_browser/ui/widgets/activity.py
index b501795ba..4cba86a11 100644
--- a/activity_browser/ui/widgets/activity.py
+++ b/activity_browser/ui/widgets/activity.py
@@ -2,6 +2,7 @@
from PySide2 import QtCore, QtWidgets
from activity_browser import actions, project_settings, signals
+from activity_browser.i18n import _
from ...bwutils import AB_metadata
from ..icons import qicons
@@ -69,17 +70,19 @@ def __init__(self, parent, read_only=True):
contents=parent.activity.get("location", ""),
)
self.location_combo.setToolTip(
- "Select an existing location from the current activity database."
- " Or add new location"
+ _(
+ "Select an existing location from the current activity database, "
+ "or add a new location."
+ )
)
self.location_combo.setEditable(
True
) # always 'editable', but not always 'enabled'
# database label
- self.database_label = QtWidgets.QLabel("Database")
+ self.database_label = QtWidgets.QLabel(_("Database"))
self.database_label.setToolTip(
- "Select a different database to duplicate activity to it"
+ _("Select a different database to copy this activity to.")
)
# database combobox
@@ -89,7 +92,7 @@ def __init__(self, parent, read_only=True):
lambda target_db: self.duplicate_confirm_dialog(target_db)
)
self.database_combo.setToolTip(
- "Use dropdown menu to duplicate activity to another database"
+ _("Use the drop-down menu to copy this activity to another database.")
)
# arrange widgets for display as a grid
@@ -100,9 +103,9 @@ def __init__(self, parent, read_only=True):
self.grid.setSpacing(6)
self.grid.setAlignment(QtCore.Qt.AlignTop)
- self.grid.addWidget(QtWidgets.QLabel("Name"), 1, 1)
+ self.grid.addWidget(QtWidgets.QLabel(_("Name")), 1, 1)
self.grid.addWidget(self.name_box, 1, 2, 1, 3)
- self.grid.addWidget(QtWidgets.QLabel("Location"), 2, 1)
+ self.grid.addWidget(QtWidgets.QLabel(_("Location")), 2, 1)
self.grid.addWidget(self.location_combo, 2, 2, 1, -1)
self.grid.addWidget(self.database_combo, 3, 2, 1, -1)
self.grid.addWidget(self.database_label, 3, 1)
@@ -158,7 +161,9 @@ def populate_database_combo(self):
self.database_combo.clear()
# first item in db combo, shown by default, is the current database
- current_db = self.parent.activity.get("database", "Error: db of Act not found")
+ current_db = self.parent.activity.get(
+ "database", _("Activity database not found")
+ )
self.database_combo.addItem(current_db)
# other items are the dbs that the activity can be duplicated to: find them and add
diff --git a/activity_browser/ui/widgets/biosphere_update.py b/activity_browser/ui/widgets/biosphere_update.py
index e5b885f39..ab52a1180 100644
--- a/activity_browser/ui/widgets/biosphere_update.py
+++ b/activity_browser/ui/widgets/biosphere_update.py
@@ -5,6 +5,7 @@
from PySide2.QtCore import Signal, Slot
from activity_browser.mod import bw2data as bd
+from activity_browser.i18n import _
from ..threading import ABThread
@@ -14,8 +15,10 @@
class BiosphereUpdater(QtWidgets.QProgressDialog):
def __init__(self, ei_versions, parent=None):
super().__init__(parent=parent)
- self.setWindowTitle("Updating '{}' database".format(bd.config.biosphere))
- self.setLabelText("Adding new flows to biosphere database")
+ self.setWindowTitle(
+ _("Updating '{database}' database", database=bd.config.biosphere)
+ )
+ self.setLabelText(_("Adding new flows to the biosphere database"))
self.setRange(0, 0)
self.show()
diff --git a/activity_browser/ui/widgets/comparison_switch.py b/activity_browser/ui/widgets/comparison_switch.py
index 76193243d..84b0e2192 100644
--- a/activity_browser/ui/widgets/comparison_switch.py
+++ b/activity_browser/ui/widgets/comparison_switch.py
@@ -3,23 +3,50 @@
from PySide2 import QtWidgets
+from activity_browser.i18n import _
+
Switches = namedtuple("switches", ("func", "method", "scenario"))
+class ComparisonMode:
+ """Stable IDs for contribution comparison modes."""
+
+ FUNCTIONAL_UNITS = "functional_units"
+ IMPACT_CATEGORIES = "impact_categories"
+ SCENARIOS = "scenarios"
+
+
class SwitchComboBox(QtWidgets.QComboBox):
"""For keeping track of contribution tab comparisons."""
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.has_scenarios = getattr(parent, "has_scenarios")
- self.switches = Switches("Reference Flows", "Impact Categories", "Scenarios")
+ self.switches = Switches(
+ _("Reference Flows"), _("Impact Categories"), _("Scenarios")
+ )
+ self.modes = Switches(
+ ComparisonMode.FUNCTIONAL_UNITS,
+ ComparisonMode.IMPACT_CATEGORIES,
+ ComparisonMode.SCENARIOS,
+ )
self.indexes = Switches(0, 1, 2)
def configure(self, has_func: bool = True, has_method: bool = True):
self.blockSignals(True)
if all([has_func, has_method]):
- self.insertItems(0, [self.switches.func, self.switches.method])
+ self.insertItem(self.indexes.func, self.switches.func, self.modes.func)
+ self.insertItem(
+ self.indexes.method, self.switches.method, self.modes.method
+ )
if self.has_scenarios:
- self.insertItems(self.indexes.scenario, [self.switches.scenario])
+ self.insertItem(
+ self.indexes.scenario, self.switches.scenario, self.modes.scenario
+ )
self.setVisible(self.count() > 0)
self.blockSignals(False)
+
+ @property
+ def current_mode(self) -> str:
+ """Return the stable ID for the selected comparison mode."""
+ return self.currentData()
diff --git a/activity_browser/ui/widgets/cutoff_menu.py b/activity_browser/ui/widgets/cutoff_menu.py
index 88bd79e5a..16a9c692b 100644
--- a/activity_browser/ui/widgets/cutoff_menu.py
+++ b/activity_browser/ui/widgets/cutoff_menu.py
@@ -17,6 +17,8 @@
QPushButton, QRadioButton, QSlider, QVBoxLayout,
QWidget)
+from activity_browser.i18n import _
+
from ..style import vertical_line
# These tuples are used in referring to the two Types and three Labels used
@@ -44,21 +46,28 @@ def __init__(self, parent=None, cutoff_value=0.05, limit_type="percent"):
self.validators.percent.setLocale(locale)
self.validators.number.setLocale(locale)
self.buttons = Types(
- QRadioButton("Minimum %"),
- QRadioButton("Cumulative %"),
- QRadioButton("Top #"))
+ QRadioButton(_("Minimum %")),
+ QRadioButton(_("Cumulative %")),
+ QRadioButton(_("Top #")))
self.buttons.percent.setChecked(True)
self.buttons.percent.setToolTip(
- "This cut-off type shows contributions of at least some percentage "
- "(for example contributions of at least 5% of the total impact)"
+ _(
+ "Show contributions that are at least a given percentage (for "
+ "example, contributions of at least 5% of the total impact)."
+ )
)
self.buttons.cum_percent.setToolTip(
- "This cut-off type shows contributions that together are some percentage "
- "(for example all highest contributors that together count up to 80% of the total impact)"
+ _(
+ "Show the largest contributions whose cumulative share reaches a "
+ "given percentage (for example, contributors that together account "
+ "for 80% of the total impact)."
+ )
)
self.buttons.number.setToolTip(
- "This cut-off type shows this number of largest contributors "
- "(for example the top 5 largest contributors)"
+ _(
+ "Show a given number of the largest contributors (for example, the "
+ "top 5 contributors)."
+ )
)
self.button_group = QButtonGroup()
self.button_group.addButton(self.buttons.percent, 0)
@@ -75,28 +84,28 @@ def __init__(self, parent=None, cutoff_value=0.05, limit_type="percent"):
QSlider(Qt.Horizontal, self),
QSlider(Qt.Horizontal, self))
self.sliders.percent.setToolTip(
- "This slider sets the cut-off percentage to show"
+ _("Set the minimum contribution percentage to show.")
)
self.sliders.cum_percent.setToolTip(
- "This slider sets the cumulative cut-off percentage to show"
+ _("Set the cumulative contribution percentage to show.")
)
self.sliders.number.setToolTip(
- "This slider sets the amount of highest contributors to show"
+ _("Set the number of largest contributors to show.")
)
- self.units = Types("minimum %", "cumulative %", "number")
+ self.units = Types(_("minimum %"), _("cumulative %"), _("number"))
self.labels = Labels(QLabel(), QLabel(), QLabel())
self.cutoff_slider_line = QLineEdit()
self.cutoff_slider_line.setToolTip(
- "This entry sets the cut-off amount"
+ _("Enter the cut-off level.")
)
self.cutoff_slider_line.setLocale(locale)
self.cutoff_slider_lft_btn = QPushButton("<")
self.cutoff_slider_lft_btn.setToolTip(
- "This button moves the cut-off value one increment"
+ _("Move the cut-off value by one increment.")
)
self.cutoff_slider_rght_btn = QPushButton(">")
self.cutoff_slider_rght_btn.setToolTip(
- "This button moves the cut-off value one increment"
+ _("Move the cut-off value by one increment.")
)
self.debounce_slider = QtCore.QTimer()
@@ -353,12 +362,12 @@ def make_layout(self):
# Cut-off types
cutoff_type = QVBoxLayout()
- cutoff_type_label = QLabel("Cut-off type")
+ cutoff_type_label = QLabel(_("Cut-off type"))
# Cut-off slider
cutoff_slider = QVBoxLayout()
cutoff_slider_set = QVBoxLayout()
- cutoff_slider_label = QLabel("Cut-off level")
+ cutoff_slider_label = QLabel(_("Cut-off level"))
self.sliders.percent.log_value = self.cutoff_value
self.sliders.percent.setInvertedAppearance(True)
self.sliders.cum_percent.setValue(self.cutoff_value)
@@ -370,7 +379,7 @@ def make_layout(self):
cutoff_slider_minmax = QHBoxLayout()
self.labels.min.setText("100%")
self.labels.max.setText("0.001%")
- self.labels.unit.setText("minimum %")
+ self.labels.unit.setText(self.units.percent)
cutoff_slider_ledit = QHBoxLayout()
self.cutoff_slider_line.setValidator(self.validators.percent)
self.cutoff_slider_lft_btn.setMaximumWidth(15)
diff --git a/activity_browser/ui/widgets/dialog.py b/activity_browser/ui/widgets/dialog.py
index 95eab30e0..8a249296d 100644
--- a/activity_browser/ui/widgets/dialog.py
+++ b/activity_browser/ui/widgets/dialog.py
@@ -7,6 +7,7 @@
from activity_browser import project_settings, signals
from activity_browser.bwutils.superstructure import get_sheet_names
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from ...bwutils.ecoinvent_biosphere_versions.ecospold2biosphereimporter import \
@@ -19,6 +20,13 @@
from ..threading import ABThread
+# These are stable data values shared with the table model. They deliberately
+# remain untranslated; only the adjacent widget labels are localized.
+FILTER_MODE_AND = "AND"
+FILTER_MODE_OR = "OR"
+FILTER_OPERATOR_BETWEEN = "<= x <="
+
+
class ForceInputDialog(QtWidgets.QDialog):
"""Due to QInputDialog not allowing 'ok' button to be disabled when
nothing is entered, we have this.
@@ -69,7 +77,7 @@ def get_text(
class TupleNameDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.name_label = QtWidgets.QLabel("New name")
+ self.name_label = QtWidgets.QLabel(_("New name"))
self.view_name = QtWidgets.QLabel()
self.input_fields = []
@@ -170,16 +178,18 @@ class ExcelReadDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle("Select file to read")
+ self.setWindowTitle(_("Select file to read"))
self.path_layout = QtWidgets.QGridLayout()
self.path = None
self.path_line = QtWidgets.QLineEdit()
self.path_line.setReadOnly(True)
self.path_line.textChanged.connect(self.changed)
- self.path_btn = QtWidgets.QPushButton("Browse")
+ self.path_btn = QtWidgets.QPushButton(_("Browse"))
self.path_btn.clicked.connect(self.browse)
- self.path_layout.addWidget(QtWidgets.QLabel("Path to file*"), 0, 0, 1, 1)
+ self.path_layout.addWidget(
+ QtWidgets.QLabel(_("Path to file*")), 0, 0, 1, 1
+ )
self.path_layout.addWidget(self.path_line, 0, 1, 1, 2)
self.path_layout.addWidget(self.path_btn, 0, 3, 1, 1)
self.path = QtWidgets.QWidget()
@@ -190,7 +200,7 @@ def __init__(self, parent=None):
self.import_sheet.addItems(["-----"])
self.import_sheet.setEnabled(True)
self.excel_option.addWidget(
- QtWidgets.QLabel("Excel sheet name")
+ QtWidgets.QLabel(_("Excel sheet name"))
) # , 0, 0, 1, 1)
self.excel_option.addWidget(self.import_sheet) # , 0, 1, 2, 1)
self.excel_sheet = QtWidgets.QWidget()
@@ -199,11 +209,11 @@ def __init__(self, parent=None):
self.csv_option = QtWidgets.QHBoxLayout()
self.field_separator = QtWidgets.QComboBox()
- for l, s in {";": ";", ",": ",", "tab": "\t"}.items():
- self.field_separator.addItem(l, s)
+ for label, separator in ((";", ";"), (",", ","), (_("tab"), "\t")):
+ self.field_separator.addItem(label, separator)
self.field_separator.setEnabled(True)
self.csv_option.addWidget(
- QtWidgets.QLabel("Separator for csv")
+ QtWidgets.QLabel(_("Separator for csv"))
) # , 0, 0, 1, 1)
self.csv_option.addWidget(self.field_separator) # , 0, 1, 2, 1)
self.csv_separator = QtWidgets.QWidget()
@@ -234,11 +244,15 @@ def __init__(self, parent=None):
@Slot(name="browseFile")
def browse(self) -> None:
- path, _ = QtWidgets.QFileDialog.getOpenFileName(
+ all_files_filter = _("All Files (*.*)")
+ path, _selected_filter = QtWidgets.QFileDialog.getOpenFileName(
parent=self,
- caption="Select scenario template file",
- filter="Excel (*.xlsx);; feather (*.feather);; CSV and Archived (*.csv *.zip *.tar *.bz2 *.gz *.xz);; All Files (*.*)",
- selectedFilter="All Files (*.*)",
+ caption=_("Select scenario template file"),
+ filter=_(
+ "Excel (*.xlsx);; feather (*.feather);; CSV and Archived "
+ "(*.csv *.zip *.tar *.bz2 *.gz *.xz);; All Files (*.*)"
+ ),
+ selectedFilter=all_files_filter,
)
if path:
self.path_line.setText(path)
@@ -284,11 +298,11 @@ class DatabaseLinkingDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle("Database linking")
+ self.setWindowTitle(_("Database linking"))
self.db_label = QtWidgets.QLabel()
self.label_choices = []
- self.grid_box = QtWidgets.QGroupBox("Database links:")
+ self.grid_box = QtWidgets.QGroupBox(_("Database links:"))
self.grid = QtWidgets.QGridLayout()
self.grid_box.setLayout(self.grid)
@@ -351,14 +365,14 @@ def construct_dialog(
def relink_sqlite(
cls, db: str, options: List[Tuple[str, List[str]]], parent=None
) -> "DatabaseLinkingDialog":
- label = "Relinking exchanges from database '{}'.".format(db)
+ label = _("Relinking exchanges from database '{database}'.", database=db)
return cls.construct_dialog(label, options, parent)
@classmethod
def relink_bw2package(
cls, options: List[Tuple[str, List[str]]], parent=None
) -> "DatabaseLinkingDialog":
- label = (
+ label = _(
"Some database(s) could not be found in the current project,"
" attempt to relink the exchanges to a different database?"
)
@@ -368,7 +382,9 @@ def relink_bw2package(
def relink_excel(
cls, options: List[Tuple[str, List[str]]], parent=None
) -> "DatabaseLinkingDialog":
- label = "Customize database links for exchanges in the imported database."
+ label = _(
+ "Customize database links for exchanges in the imported database."
+ )
return cls.construct_dialog(label, options, parent)
@@ -383,7 +399,7 @@ class DatabaseLinkingResultsDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle("Relinking database results")
+ self.setWindowTitle(_("Relinking database results"))
button = QtWidgets.QDialogButtonBox.Ok
self.buttonBox = QtWidgets.QDialogButtonBox(button)
@@ -410,14 +426,26 @@ def construct_results_dialog(
obj = cls(parent)
for k, results in link_results.items():
obj.databases_relinked.addWidget(
- QtWidgets.QLabel(f"{k} = {results[1]} successfully linked")
+ QtWidgets.QLabel(
+ _(
+ "{database} = {count} successfully linked",
+ database=k,
+ count=results[1],
+ )
+ )
)
obj.databases_relinked.addWidget(
- QtWidgets.QLabel(f"{k} = {results[0]} flows failed to link")
+ QtWidgets.QLabel(
+ _(
+ "{database} = {count} flows failed to link",
+ database=k,
+ count=results[0],
+ )
+ )
)
obj.exchangesUnlinked.addWidget(
- QtWidgets.QLabel("Up to 5 unlinked exchanges (click to open)")
+ QtWidgets.QLabel(_("Up to 5 unlinked exchanges (click to open)"))
)
for act, key in unlinked_exchanges.items():
button = QtWidgets.QPushButton(act.as_dict()["name"])
@@ -454,11 +482,11 @@ class ActivityLinkingDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle("Activity linking")
+ self.setWindowTitle(_("Activity linking"))
self.db_label = QtWidgets.QLabel()
self.label_choices = []
- self.grid_box = QtWidgets.QGroupBox("Database links:")
+ self.grid_box = QtWidgets.QGroupBox(_("Database links:"))
self.grid = QtWidgets.QGridLayout()
self.grid_box.setLayout(self.grid)
@@ -521,7 +549,7 @@ def construct_dialog(
def relink_sqlite(
cls, act: str, options: List[Tuple[str, List[str]]], parent=None
) -> "ActivityLinkingDialog":
- label = "Relinking exchanges from activity '{}'.".format(act)
+ label = _("Relinking exchanges from activity '{activity}'.", activity=act)
return cls.construct_dialog(label, options, parent)
@@ -535,7 +563,7 @@ class ActivityLinkingResultsDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle("Relinking database results")
+ self.setWindowTitle(_("Relinking database results"))
button = QtWidgets.QDialogButtonBox.Ok
self.buttonBox = QtWidgets.QDialogButtonBox(button)
@@ -562,14 +590,26 @@ def construct_results_dialog(
obj = cls(parent)
for k, results in link_results.items():
obj.databases_relinked.addWidget(
- QtWidgets.QLabel(f"{k} = {results[1]} successfully linked")
+ QtWidgets.QLabel(
+ _(
+ "{database} = {count} successfully linked",
+ database=k,
+ count=results[1],
+ )
+ )
)
obj.databases_relinked.addWidget(
- QtWidgets.QLabel(f"{k} = {results[0]} flows failed to link")
+ QtWidgets.QLabel(
+ _(
+ "{database} = {count} flows failed to link",
+ database=k,
+ count=results[0],
+ )
+ )
)
obj.exchangesUnlinked.addWidget(
- QtWidgets.QLabel("Up to 5 unlinked exchanges (click to open)")
+ QtWidgets.QLabel(_("Up to 5 unlinked exchanges (click to open)"))
)
for act, key in unlinked_exchanges.items():
button = QtWidgets.QPushButton(act.as_dict()["name"])
@@ -602,7 +642,7 @@ def open_activity(self):
class DefaultBiosphereDialog(QtWidgets.QProgressDialog):
def __init__(self, version, parent=None):
super().__init__(parent=parent)
- self.setWindowTitle("Biosphere and impact categories")
+ self.setWindowTitle(_("Biosphere and impact categories"))
self.setRange(0, 3)
self.setModal(Qt.ApplicationModal)
@@ -645,16 +685,34 @@ def __init__(self, version, parent=None):
self.version = version
def run_safely(self):
- project = f"{bd.projects.current}"
+ project = bd.projects.current
if "biosphere3" not in bd.databases:
- self.update.emit(0, "Creating default biosphere for {}".format(project))
+ self.update.emit(
+ 0,
+ _(
+ "Creating default biosphere for {project}",
+ project=project,
+ ),
+ )
create_default_biosphere3(self.version)
project_settings.add_db("biosphere3")
if not len(bd.methods):
- self.update.emit(1, "Creating default LCIA methods for {}".format(project))
+ self.update.emit(
+ 1,
+ _(
+ "Creating default LCIA methods for {project}",
+ project=project,
+ ),
+ )
bi.create_default_lcia_methods()
if not len(bi.migrations):
- self.update.emit(2, "Creating core data migrations for {}".format(project))
+ self.update.emit(
+ 2,
+ _(
+ "Creating core data migrations for {project}",
+ project=project,
+ ),
+ )
bi.create_core_migrations()
@@ -696,11 +754,12 @@ def __init__(
filters: dict = None,
selected_column: int = 0,
column_types: dict = {},
+ column_labels: dict = None,
parent=None,
):
super().__init__(parent)
self.setWindowIcon(qicons.filter)
- self.setWindowTitle("Manage table filters")
+ self.setWindowTitle(_("Manage table filters"))
# set given filters, if any
if isinstance(filters, dict):
@@ -724,10 +783,11 @@ def __init__(
filter_types=filter_types,
)
self.tabs.append(tab)
- self.tab_widget.addTab(tab, col_name)
+ display_name = (column_labels or {}).get(col_id, col_name)
+ self.tab_widget.addTab(tab, str(display_name))
# add AND/OR choice button.
- self.and_or_buttons = AndOrRadioButtons(label_text="Combine columns:")
+ self.and_or_buttons = AndOrRadioButtons(label_text=_("Combine columns:"))
# in the extremely unlikely event there is only 1 column, hide the AND/OR option.
if len(column_names) == 1:
self.and_or_buttons.hide()
@@ -747,8 +807,9 @@ def __init__(
self.setLayout(layout)
# set the column that launched the dialog as the open tab
- self.tab_widget.setCurrentIndex(self.col_id_2_tab_id[selected_column])
- self.tabs[selected_column].filter_rows[-1].filter_query_line.setFocus()
+ tab_id = self.col_id_2_tab_id[selected_column]
+ self.tab_widget.setCurrentIndex(tab_id)
+ self.tabs[tab_id].filter_rows[-1].filter_query_line.setFocus()
@property
def get_filters(self) -> dict:
@@ -776,14 +837,20 @@ def __init__(
filter_types: dict,
column_type: str = "str",
preset_type: str = None,
+ column_label: str = None,
parent=None,
):
super().__init__(parent)
self.setWindowIcon(qicons.filter)
- self.setWindowTitle("Add filter")
+ self.setWindowTitle(_("Add filter"))
# Create filter label and buttons
- label = QtWidgets.QLabel("Define a filter for column '{}'".format(column_name))
+ label = QtWidgets.QLabel(
+ _(
+ "Define a filter for column '{column_name}'",
+ column_name=column_label if column_label is not None else column_name,
+ )
+ )
if column_type == "num":
self.filter_row = NumFilterRow(
@@ -849,14 +916,14 @@ def __init__(
self.add = QtWidgets.QToolButton()
self.add.setIcon(qicons.add)
- self.add.setToolTip("Add a new filter for this column")
+ self.add.setToolTip(_("Add a new filter for this column"))
self.add.clicked.connect(self.add_row)
self.and_or_buttons = AndOrRadioButtons(
- label_text="Combine filters within column:"
+ label_text=_("Combine filters within column:")
)
if self.col_type == "str":
- self.and_or_buttons.set_state("OR")
+ self.and_or_buttons.set_state(FILTER_MODE_OR)
self.filter_rows = []
self.filter_widget_layout = QtWidgets.QVBoxLayout()
@@ -973,16 +1040,20 @@ def __init__(
self.idx = idx
self.filter_types = filter_types
self.filter_type = self.filter_types[self.column_type]
+ self.filter_ids = self.filter_types.get(
+ self.column_type + "_ids", self.filter_type
+ )
self.parent = parent
self.row_layout = QtWidgets.QHBoxLayout()
# create a 'filter type' combobox
self.filter_type_box = QtWidgets.QComboBox()
- self.filter_type_box.addItems(self.filter_type)
+ for filter_id, label in zip(self.filter_ids, self.filter_type):
+ self.filter_type_box.addItem(label, filter_id)
# set a preset type if given
if isinstance(preset_type, str):
- self.filter_type_box.setCurrentIndex(self.filter_type.index(preset_type))
+ self.filter_type_box.setCurrentIndex(self.filter_ids.index(preset_type))
# add tooltip for every type option
for i, tt in enumerate(self.filter_types[self.column_type + "_tt"]):
self.filter_type_box.setItemData(i, tt, Qt.ToolTipRole)
@@ -995,7 +1066,7 @@ def __init__(
# add buttons to remove the row
self.remove = QtWidgets.QToolButton()
self.remove.setIcon(qicons.delete)
- self.remove.setToolTip("Remove this filter")
+ self.remove.setToolTip(_("Remove this filter"))
self.remove.clicked.connect(self.self_destruct)
@property
@@ -1030,7 +1101,7 @@ def __init__(
super().__init__(idx, filter_types, remove_option, preset_type, parent)
# create case-sensitive box
- self.case_sensitive_text = QtWidgets.QLabel("Case Sensitive:")
+ self.case_sensitive_text = QtWidgets.QLabel(_("Case Sensitive:"))
self.filter_case_sensitive_check = QtWidgets.QCheckBox()
# assemble the layout
@@ -1064,14 +1135,14 @@ def get_state(self) -> tuple:
if query_line == "":
return None
- selected_type = self.filter_type_box.currentText()
+ selected_type = self.filter_type_box.currentData()
selected_query = self.filter_query_line.text()
case_sensitive = self.filter_case_sensitive_check.isChecked()
return selected_type, selected_query, case_sensitive
def set_state(self, state: tuple) -> None:
selected_type, selected_query, case_sensitive = state
- self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type))
+ self.filter_type_box.setCurrentIndex(self.filter_ids.index(selected_type))
self.filter_query_line.setText(selected_query)
self.filter_case_sensitive_check.setChecked(case_sensitive)
@@ -1137,9 +1208,9 @@ def get_state(self) -> tuple:
if query_line == "":
return None
- selected_type = self.filter_type_box.currentText()
+ selected_type = self.filter_type_box.currentData()
selected_query = self.filter_query_line.text()
- if self.filter_type_box.currentText() == "<= x <=":
+ if selected_type == FILTER_OPERATOR_BETWEEN:
selected_query = (
self.filter_query_line0.text(),
self.filter_query_line.text(),
@@ -1149,8 +1220,8 @@ def get_state(self) -> tuple:
def set_state(self, state: tuple) -> None:
selected_type, selected_query = state
self.set_input_changes()
- self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type))
- if selected_type == "<= x <=":
+ self.filter_type_box.setCurrentIndex(self.filter_ids.index(selected_type))
+ if selected_type == FILTER_OPERATOR_BETWEEN:
self.filter_query_line0.setText(selected_query[0])
self.filter_query_line.setText(selected_query[1])
else:
@@ -1158,7 +1229,7 @@ def set_state(self, state: tuple) -> None:
def set_input_changes(self) -> None:
# enable whether the extra input line is visible
- if self.filter_type_box.currentText() == "<= x <=":
+ if self.filter_type_box.currentData() == FILTER_OPERATOR_BETWEEN:
self.filter_query_line0.show()
else:
self.filter_query_line0.hide()
@@ -1191,8 +1262,10 @@ def __init__(self, label_text: str = "", state: str = None, parent=None):
# create an AND/OR widget
layout = QtWidgets.QHBoxLayout()
self.btn_group = QtWidgets.QButtonGroup()
- self.AND = QtWidgets.QRadioButton("AND")
- self.OR = QtWidgets.QRadioButton("OR")
+ self.AND = QtWidgets.QRadioButton(_("AND"))
+ self.OR = QtWidgets.QRadioButton(_("OR"))
+ self.AND.setProperty("filter_mode", FILTER_MODE_AND)
+ self.OR.setProperty("filter_mode", FILTER_MODE_OR)
self.btn_group.addButton(self.AND)
self.btn_group.addButton(self.OR)
layout.addStretch()
@@ -1201,23 +1274,25 @@ def __init__(self, label_text: str = "", state: str = None, parent=None):
layout.addWidget(self.OR)
self.setLayout(layout)
self.setToolTip(
- "Choose how filters combine with each other.\n"
- "AND must satisfy all filters, OR must satisfy at least one filter."
+ _(
+ "Choose how filters combine with each other.\n"
+ "AND must satisfy all filters, OR must satisfy at least one filter."
+ )
)
# set the state if one was given, otherwise, assume AND
if isinstance(state, str):
self.set_state(state)
else:
- self.set_state("AND")
+ self.set_state(FILTER_MODE_AND)
@property
def get_state(self) -> str:
- return self.btn_group.checkedButton().text()
+ return self.btn_group.checkedButton().property("filter_mode")
def set_state(self, state: str) -> None:
x = True
- if state == "OR":
+ if state == FILTER_MODE_OR:
x = False
self.AND.setChecked(x)
self.OR.setChecked(not x)
@@ -1227,10 +1302,12 @@ class ProjectDeletionDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.title = "Confirm project deletion"
+ self.title = _("Confirm project deletion")
self.label = QtWidgets.QLabel(
- "Final confirmation to remove data from the hard disk.\n"
- + "Warning: Non reversible process!"
+ _(
+ "Final confirmation to remove data from the hard disk.\n"
+ "Warning: Non reversible process!"
+ )
)
self.check = QtWidgets.QVBoxLayout()
self.bttn = QtWidgets.QCheckBox()
@@ -1253,9 +1330,11 @@ def construct_project_deletion_dialog(
cls, parent: QtWidgets.QWidget = None, prjctName: str = None
) -> "ProjectDeletionDialog":
obj = cls(parent)
- obj.title = f"Confirm deletion of {prjctName}"
+ obj.title = _("Confirm deletion of {project}", project=prjctName)
obj.setWindowTitle(obj.title)
- obj.bttn = QtWidgets.QCheckBox(f"Remove {prjctName} from the hard disk")
+ obj.bttn = QtWidgets.QCheckBox(
+ _("Remove {project} from the hard disk", project=prjctName)
+ )
obj.bttn.setChecked(False)
obj.check.addWidget(obj.bttn)
obj.updateGeometry()
@@ -1272,16 +1351,19 @@ class ScenarioDatabaseDialog(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
- self.setWindowTitle("Linking scenario databases")
+ self.setWindowTitle(_("Linking scenario databases"))
self.label = QtWidgets.QLabel(
- "The following database(s) in the scenario file cannot be found in your project.\n\n"
- "Please indicate the corresponding database(s), or cancel the import if this is not"
- " possible. (Warning: this process may take a few minutes for large scenario files)"
+ _(
+ "The following database(s) in the scenario file cannot be found in "
+ "your project.\n\nPlease indicate the corresponding database(s), or "
+ "cancel the import if this is not possible. (Warning: this process "
+ "may take a few minutes for large scenario files)"
+ )
)
self.label_choices = []
- self.grid_box = QtWidgets.QGroupBox("Databases:")
+ self.grid_box = QtWidgets.QGroupBox(_("Databases:"))
self.grid = QtWidgets.QGridLayout()
self.grid_box.setLayout(self.grid)
@@ -1336,20 +1418,22 @@ class LocationLinkingDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle("Activity Location linking")
+ self.setWindowTitle(_("Activity Location linking"))
self.loc_label = QtWidgets.QLabel()
self.label_choices = []
- self.grid_box = QtWidgets.QGroupBox("Location link:")
+ self.grid_box = QtWidgets.QGroupBox(_("Location link:"))
self.grid = QtWidgets.QGridLayout()
self.grid_box.setLayout(self.grid)
self.use_alternatives_label = QtWidgets.QLabel(
- "Use generic alternatives as fallback:"
+ _("Use generic alternatives as fallback:")
)
self.use_alternatives_label.setToolTip(
- "If the chosen location is not found, try matching the selected "
- "locations below too"
+ _(
+ "If the chosen location is not found, try matching the selected "
+ "locations below too"
+ )
)
self.use_row = QtWidgets.QCheckBox("RoW")
self.use_row.setChecked(True)
@@ -1413,8 +1497,9 @@ def construct_dialog(
def relink_location(
cls, act_name: str, options: List[Tuple[str, List[str]]], parent=None
) -> "LocationLinkingDialog":
- label = "Relinking exchanges from activity '{}' to a new location.".format(
- act_name
+ label = _(
+ "Relinking exchanges from activity '{activity}' to a new location.",
+ activity=act_name,
)
return cls.construct_dialog(label, options, parent)
@@ -1423,7 +1508,7 @@ class EcoinventVersionDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super(EcoinventVersionDialog, self).__init__(parent)
- self.setWindowTitle("Choose a biosphere version")
+ self.setWindowTitle(_("Choose a biosphere version"))
self.buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel,
@@ -1433,7 +1518,7 @@ def __init__(self, parent=None):
self.layout = QtWidgets.QVBoxLayout()
self.label = QtWidgets.QLabel(
- "Choose which biosphere version\n" "you would like to use"
+ _("Choose which biosphere version\nyou would like to use")
)
self.options = QtWidgets.QComboBox()
diff --git a/activity_browser/ui/widgets/message.py b/activity_browser/ui/widgets/message.py
index f53c45f5d..d9d7fb950 100644
--- a/activity_browser/ui/widgets/message.py
+++ b/activity_browser/ui/widgets/message.py
@@ -2,6 +2,8 @@
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QMessageBox
+from activity_browser.i18n import _
+
def parameter_save_errorbox(parent, error) -> int:
"""Construct a messagebox using the given error
@@ -9,10 +11,10 @@ def parameter_save_errorbox(parent, error) -> int:
"""
msgbox = QMessageBox(
QMessageBox.Warning,
- "Cannot save parameters",
- (
- "An error occurred while saving parameters."
- "\nDiscard changes or cancel and continue editing?"
+ _("Cannot save parameters"),
+ _(
+ "An error occurred while saving parameters.\nDiscard changes or "
+ "cancel and continue editing?"
),
QMessageBox.Discard | QMessageBox.Cancel,
parent,
diff --git a/activity_browser/ui/wizards/db_export_wizard.py b/activity_browser/ui/wizards/db_export_wizard.py
index 07e4d3322..e242f9279 100644
--- a/activity_browser/ui/wizards/db_export_wizard.py
+++ b/activity_browser/ui/wizards/db_export_wizard.py
@@ -5,6 +5,7 @@
from PySide2.QtCore import Slot
from activity_browser.bwutils import exporters as exp
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
EXPORTERS = {
@@ -28,7 +29,7 @@ class DatabaseExportWizard(QtWidgets.QWizard):
def __init__(self, parent=None):
super().__init__(parent=parent)
- self.setWindowTitle("Database export wizard")
+ self.setWindowTitle(_("Database export wizard"))
self.export_page = ExportDatabasePage(self)
self.pages = [self.export_page]
for i, page in enumerate(self.pages):
@@ -40,7 +41,7 @@ def accept(self) -> None:
def perform_export(self) -> None:
db_name = self.field("database_choice")
- export_as = self.field("export_option")
+ export_as = self.export_page.selected_exporter
out_path = self.field("output_path")
# Ensure that extension matches export_option.
path, ext = os.path.splitext(out_path)
@@ -60,25 +61,27 @@ class ExportDatabasePage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent=parent)
+ self.setTitle(_("Export database"))
self.wizard = parent
self.database = QtWidgets.QComboBox()
self.export_option = QtWidgets.QComboBox()
- self.export_option.addItems(list(EXPORTERS))
+ for exporter_id in EXPORTERS:
+ self.export_option.addItem(exporter_id, exporter_id)
self.database.currentIndexChanged.connect(self.changed)
self.output_dir = QtWidgets.QLineEdit()
self.output_dir.setReadOnly(True)
- self.browse_button = QtWidgets.QPushButton("Browse")
+ self.browse_button = QtWidgets.QPushButton(_("Browse"))
self.browse_button.clicked.connect(self.browse)
self.complete = False
- box = QtWidgets.QGroupBox("Database selection:")
+ box = QtWidgets.QGroupBox(_("Database selection:"))
grid = QtWidgets.QGridLayout()
- grid.addWidget(QtWidgets.QLabel("Database:"), 0, 0, 1, 1)
+ grid.addWidget(QtWidgets.QLabel(_("Database:")), 0, 0, 1, 1)
grid.addWidget(self.database, 0, 1, 1, 2)
- grid.addWidget(QtWidgets.QLabel("Exported as:"), 1, 0, 1, 1)
+ grid.addWidget(QtWidgets.QLabel(_("Exported as:")), 1, 0, 1, 1)
grid.addWidget(self.export_option, 1, 1, 1, 2)
grid.addWidget(
- QtWidgets.QLabel("Exported data is stored in the directory below:"),
+ QtWidgets.QLabel(_("Exported data is stored in the directory below:")),
2,
0,
1,
@@ -93,9 +96,15 @@ def __init__(self, parent=None):
self.setFinalPage(True)
self.registerField("database_choice", self.database, "currentText")
- self.registerField("export_option", self.export_option, "currentText")
+ self.registerField("export_option", self.export_option, "currentIndex")
self.registerField("output_path*", self.output_dir)
+ @property
+ def selected_exporter(self) -> str:
+ """Return the stable exporter ID stored separately from its label."""
+
+ return self.export_option.currentData()
+
def initializePage(self):
self.wizard.setButtonLayout(
[
@@ -110,7 +119,7 @@ def initializePage(self):
self.output_dir.setText(bd.projects.output_dir)
def changed(self):
- self.complete = False if self.database.currentText() == "-----" else True
+ self.complete = self.database.currentIndex() > 0
self.completeChanged.emit()
def isComplete(self):
@@ -118,9 +127,9 @@ def isComplete(self):
@Slot(name="browseFile")
def browse(self) -> None:
- file_filter = self.FILTERS[self.field("export_option")]
- path, _ = QtWidgets.QFileDialog.getSaveFileName(
- parent=self, caption="Save database", filter=file_filter
- )
+ file_filter = _(self.FILTERS[self.selected_exporter])
+ path = QtWidgets.QFileDialog.getSaveFileName(
+ parent=self, caption=_("Save database"), filter=file_filter
+ )[0]
if path:
self.output_dir.setText(path)
diff --git a/activity_browser/ui/wizards/db_import_wizard.py b/activity_browser/ui/wizards/db_import_wizard.py
index 8072d7c0f..a32b75168 100644
--- a/activity_browser/ui/wizards/db_import_wizard.py
+++ b/activity_browser/ui/wizards/db_import_wizard.py
@@ -19,6 +19,7 @@
from py7zr import py7zr
from activity_browser.bwutils import errors
+from activity_browser.i18n import _
from activity_browser.mod import bw2data as bd
from ...bwutils.importers import ABExcelImporter, ABPackage
@@ -48,7 +49,7 @@ class DatabaseImportWizard(QtWidgets.QWizard):
def __init__(self, parent=None):
super().__init__(parent)
self.downloader = ABEcoinventDownloader()
- self.setWindowTitle("Database Import Wizard")
+ self.setWindowTitle(_("Database Import Wizard"))
self.setWindowModality(QtCore.Qt.ApplicationModal)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.setWindowFlags(QtCore.Qt.Sheet)
@@ -198,11 +199,13 @@ class ImportTypePage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
self.wizard = parent
- self.radio_buttons = [QtWidgets.QRadioButton(o[0]) for o in self.OPTIONS]
+ self.radio_buttons = [
+ QtWidgets.QRadioButton(_(option[0])) for option in self.OPTIONS
+ ]
self.radio_buttons[0].setChecked(True)
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Type of data import:")
+ box = QtWidgets.QGroupBox(_("Type of data import:"))
box_layout = QtWidgets.QVBoxLayout()
for i, button in enumerate(self.radio_buttons):
box_layout.addWidget(button)
@@ -227,12 +230,17 @@ class RemoteImportPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
self.wizard = parent
- self.radio_buttons = [QtWidgets.QRadioButton(o[0]) for o in self.OPTIONS]
+ self.radio_buttons = [
+ QtWidgets.QRadioButton(
+ option[0] if option[1] == "forwast" else _(option[0])
+ )
+ for option in self.OPTIONS
+ ]
self.radio_buttons[0].setChecked(True)
self.has_valid_remote_creds = False
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Data source:")
+ box = QtWidgets.QGroupBox(_("Data source:"))
box_layout = QtWidgets.QVBoxLayout()
for i, button in enumerate(self.radio_buttons):
box_layout.addWidget(button)
@@ -265,11 +273,13 @@ class LocalImportPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
self.wizard = parent
- self.radio_buttons = [QtWidgets.QRadioButton(o[0]) for o in self.OPTIONS]
+ self.radio_buttons = [
+ QtWidgets.QRadioButton(_(option[0])) for option in self.OPTIONS
+ ]
self.radio_buttons[0].setChecked(True)
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Data source:")
+ box = QtWidgets.QGroupBox(_("Data source:"))
box_layout = QtWidgets.QVBoxLayout()
for i, button in enumerate(self.radio_buttons):
box_layout.addWidget(button)
@@ -289,11 +299,13 @@ def __init__(self, parent=None):
super().__init__(parent)
self.path_edit = QtWidgets.QLineEdit()
self.registerField("dirpath*", self.path_edit)
- self.browse_button = QtWidgets.QPushButton("Browse")
+ self.browse_button = QtWidgets.QPushButton(_("Browse"))
self.browse_button.clicked.connect(self.get_directory)
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Choose location of existing ecospold2 directory:")
+ box = QtWidgets.QGroupBox(
+ _("Choose location of existing ecospold2 directory:")
+ )
box_layout = QtWidgets.QVBoxLayout()
box_layout.addWidget(self.path_edit)
browse_lay = QtWidgets.QHBoxLayout()
@@ -308,23 +320,26 @@ def __init__(self, parent=None):
@Slot(name="getDirectory")
def get_directory(self) -> None:
path = QtWidgets.QFileDialog.getExistingDirectory(
- self, "Select directory with ecospold2 files"
+ self, _("Select directory with ecospold2 files")
)
self.path_edit.setText(path)
def validatePage(self):
dir_path = Path(self.field("dirpath") or "")
if not dir_path.is_dir():
- warning = "Not a directory:
{}".format(dir_path)
- QtWidgets.QMessageBox.warning(self, "Not a directory!", warning)
+ warning = _("Not a directory:
{path}", path=dir_path)
+ QtWidgets.QMessageBox.warning(self, _("Not a directory!"), warning)
return False
else:
- count = sum(1 for _ in dir_path.glob("*.spold"))
+ count = sum(1 for _file in dir_path.glob("*.spold"))
if not count:
- warning = "No ecospold files found in this directory:
{}".format(
- dir_path
+ warning = _(
+ "No ecospold files found in this directory:
{path}",
+ path=dir_path,
+ )
+ QtWidgets.QMessageBox.warning(
+ self, _("No ecospold files!"), warning
)
- QtWidgets.QMessageBox.warning(self, "No ecospold files!", warning)
return False
else:
return True
@@ -339,21 +354,21 @@ def __init__(self, parent=None):
self.wizard = parent
self.path_edit = QtWidgets.QLineEdit()
self.registerField("archive_path*", self.path_edit)
- self.browse_button = QtWidgets.QPushButton("Browse")
+ self.browse_button = QtWidgets.QPushButton(_("Browse"))
self.browse_button.clicked.connect(self.get_archive)
self.stored_dbs = {}
self.stored_combobox = QtWidgets.QComboBox()
self.stored_combobox.activated.connect(self.update_stored)
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Choose location of 7z archive:")
+ box = QtWidgets.QGroupBox(_("Choose location of 7z archive:"))
box_layout = QtWidgets.QVBoxLayout()
box_layout.addWidget(self.path_edit)
browse_lay = QtWidgets.QHBoxLayout()
browse_lay.addWidget(self.browse_button)
browse_lay.addStretch(1)
box_layout.addLayout(browse_lay)
- box_layout.addWidget(QtWidgets.QLabel("Previous downloads:"))
+ box_layout.addWidget(QtWidgets.QLabel(_("Previous downloads:")))
box_layout.addWidget(self.stored_combobox)
box.setLayout(box_layout)
box.setStyleSheet(style_group_box.border_title)
@@ -383,7 +398,9 @@ def update_stored(self, index: int) -> None:
@Slot(name="getArchiveFile")
def get_archive(self) -> None:
- path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select 7z archive")
+ path, _selected_filter = QtWidgets.QFileDialog.getOpenFileName(
+ self, _("Select 7z archive")
+ )
if path:
self.path_edit.setText(path)
@@ -393,17 +410,18 @@ def validatePage(self):
if path.suffix == ".7z":
return True
else:
- warning = (
- "Unexpected filetype: {}
Import might not work."
- + "Continue anyway?"
- ).format(path.suffix)
+ warning = _(
+ "Unexpected filetype: {suffix}
"
+ "Import might not work. Continue anyway?",
+ suffix=path.suffix,
+ )
answer = QtWidgets.QMessageBox.question(
- self, "Not a 7zip archive!", warning
+ self, _("Not a 7zip archive!"), warning
)
return answer == QtWidgets.QMessageBox.Yes
else:
- warning = "File not found:
{}".format(path)
- QtWidgets.QMessageBox.warning(self, "File not found!", warning)
+ warning = _("File not found:
{path}", path=path)
+ QtWidgets.QMessageBox.warning(self, _("File not found!"), warning)
return False
def nextId(self):
@@ -418,7 +436,7 @@ def __init__(self, parent=None):
self.registerField("db_name*", self.name_edit)
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Name of the new database:")
+ box = QtWidgets.QGroupBox(_("Name of the new database:"))
box_layout = QtWidgets.QVBoxLayout()
box_layout.addWidget(self.name_edit)
box.setLayout(box_layout)
@@ -443,10 +461,13 @@ def initializePage(self):
def validatePage(self):
db_name = self.name_edit.text()
if db_name in bd.databases:
- warning = "Database {} already exists in project {}!".format(
- db_name, bd.projects.current
+ warning = _(
+ "Database {database} already exists in project "
+ "{project}!",
+ database=db_name,
+ project=bd.projects.current,
)
- QtWidgets.QMessageBox.warning(self, "Database exists!", warning)
+ QtWidgets.QMessageBox.warning(self, _("Database exists!"), warning)
return False
else:
return True
@@ -460,13 +481,13 @@ def __init__(self, parent=None):
super().__init__(parent)
self.wizard = parent
self.setCommitPage(True)
- self.setButtonText(QtWidgets.QWizard.CommitButton, "Import Database")
- self.current_project_label = QtWidgets.QLabel("empty")
- self.db_name_label = QtWidgets.QLabel("empty")
- self.path_label = QtWidgets.QLabel("empty")
+ self.setButtonText(QtWidgets.QWizard.CommitButton, _("Import Database"))
+ self.current_project_label = QtWidgets.QLabel()
+ self.db_name_label = QtWidgets.QLabel()
+ self.path_label = QtWidgets.QLabel()
layout = QtWidgets.QVBoxLayout()
- box = QtWidgets.QGroupBox("Import Summary:")
+ box = QtWidgets.QGroupBox(_("Import Summary:"))
box_layout = QtWidgets.QVBoxLayout()
box_layout.addWidget(self.current_project_label)
box_layout.addWidget(self.db_name_label)
@@ -478,39 +499,55 @@ def __init__(self, parent=None):
def initializePage(self):
self.current_project_label.setText(
- "Current Project: {}".format(bd.projects.current)
+ _("Current Project: {project}", project=bd.projects.current)
)
self.db_name_label.setText(
- "Name of the new database: {}".format(self.field("db_name"))
+ _(
+ "Name of the new database: {database}",
+ database=self.field("db_name"),
+ )
)
if self.wizard.import_type == "directory":
self.path_label.setText(
- "Path to directory with ecospold files:
{}".format(
- self.field("dirpath")
+ _(
+ "Path to directory with ecospold files:
{path}",
+ path=self.field("dirpath"),
)
)
elif self.wizard.import_type == "archive":
self.path_label.setText(
- "Path to 7z archive:
{}".format(self.field("archive_path"))
+ _(
+ "Path to 7z archive:
{path}",
+ path=self.field("archive_path"),
+ )
)
elif self.wizard.import_type == "forwast":
self.path_label.setOpenExternalLinks(True)
self.path_label.setText(
- 'Download forwast from '
- + "https://lca-net.com/projects/show/forwast/"
+ _(
+ "Download forwast from {link}",
+ link=(
+ ''
+ "https://lca-net.com/projects/show/forwast/"
+ ),
+ )
)
elif self.wizard.import_type == "local":
self.path_label.setText(
- "Path to local file:
{}".format(self.field("archive_path"))
+ _(
+ "Path to local file:
{path}",
+ path=self.field("archive_path"),
+ )
)
else:
self.path_label.setText(
- "Ecoinvent version: {}
"
- "Ecoinvent system model: {}
"
- "Dependent Database: {}".format(
- self.wizard.version,
- self.wizard.system_model,
- bd.config.biosphere,
+ _(
+ "Ecoinvent version: {version}
"
+ "Ecoinvent system model: {system_model}
"
+ "Dependent Database: {database}",
+ version=self.wizard.version,
+ system_model=self.wizard.system_model,
+ database=bd.config.biosphere,
)
)
@@ -539,24 +576,24 @@ def __init__(self, parent=None):
self.complete = False
self.relink_data = {}
self.extraction_label = QtWidgets.QLabel(
- "Extracting XML data from ecospold files:"
+ _("Extracting XML data from ecospold files:")
)
self.extraction_progressbar = QtWidgets.QProgressBar()
- self.strategy_label = QtWidgets.QLabel("Applying brightway2 strategies:")
+ self.strategy_label = QtWidgets.QLabel(_("Applying brightway2 strategies:"))
self.strategy_progressbar = QtWidgets.QProgressBar()
- db_label = QtWidgets.QLabel("Writing datasets to SQLite database:")
+ db_label = QtWidgets.QLabel(_("Writing datasets to SQLite database:"))
self.db_progressbar = QtWidgets.QProgressBar()
- finalizing_label = QtWidgets.QLabel("Finalizing:")
+ finalizing_label = QtWidgets.QLabel(_("Finalizing:"))
self.finalizing_progressbar = QtWidgets.QProgressBar()
- self.finished_label = QtWidgets.QLabel("")
+ self.finished_label = QtWidgets.QLabel()
layout = QtWidgets.QVBoxLayout()
self.download_label = QtWidgets.QLabel(
- "Downloading data from ecoinvent homepage:"
+ _("Downloading data from ecoinvent homepage:")
)
self.download_label.setVisible(False)
self.download_progressbar = QtWidgets.QProgressBar()
- self.unarchive_label = QtWidgets.QLabel("Decompressing the 7z archive:")
+ self.unarchive_label = QtWidgets.QLabel(_("Decompressing the 7z archive:"))
self.unarchive_progressbar = QtWidgets.QProgressBar()
layout.addWidget(self.download_label)
layout.addWidget(self.download_progressbar)
@@ -681,7 +718,7 @@ def update_finished(self) -> None:
self.main_worker_thread.quit()
self.finalizing_progressbar.setMaximum(1)
self.finalizing_progressbar.setValue(1)
- self.finished_label.setText("Finished!")
+ self.finished_label.setText(_("Finished!"))
self.complete = True
self.completeChanged.emit()
@@ -714,9 +751,11 @@ def fix_db_import(self, missing: set) -> None:
# If the user at any point did not accept their choice, fail.
import_signals.import_failure.emit(
(
- "Missing databases",
- "Package data links to database names that do not exist: {}".format(
- missing
+ _("Missing databases"),
+ _(
+ "Package data links to database names that do not exist: "
+ "{databases}",
+ databases=missing,
),
)
)
@@ -740,8 +779,8 @@ def fix_excel_import(self, exchanges: list, missing: set) -> None:
self.relink_data = linker.links
else:
error = (
- "Unlinked exchanges",
- "Excel data contains exchanges that could not be linked.",
+ _("Unlinked exchanges"),
+ _("Excel data contains exchanges that could not be linked."),
exchanges,
)
import_signals.import_failure_detailed.emit(
@@ -759,9 +798,10 @@ def report_failed_unarchive(self, file: str) -> None:
self.main_worker_thread.exit(1)
error = (
- "Corrupted (.7z) archive",
- "The archive '{}' is corrupted, please remove and re-download it.".format(
- file
+ _("Corrupted (.7z) archive"),
+ _(
+ "The archive '{path}' is corrupted, please remove and re-download it.",
+ path=file,
),
)
import_signals.import_failure_detailed.emit(
@@ -919,9 +959,12 @@ def run_import(self, import_dir: Path) -> None:
self.delete_canceled_db()
import_signals.import_failure.emit(
(
- "Missing exchanges",
- "The import failed because the biosphere3 database of this project is incompatible with the "
- "version of ecoinvent that you're trying to install",
+ _("Missing exchanges"),
+ _(
+ "The import failed because the biosphere3 database of this "
+ "project is incompatible with the version of ecoinvent that "
+ "you're trying to install"
+ ),
)
)
@@ -967,14 +1010,14 @@ def run_local_import(self):
self.delete_canceled_db()
import_signals.import_failure.emit(
(
- "Missing exchanges",
- "The import has failed, likely due missing exchanges.",
+ _("Missing exchanges"),
+ _("The import has failed, likely due missing exchanges."),
)
)
except errors.UnknownObject as e:
# BW2Package import failed because the object was not understood
self.delete_canceled_db()
- import_signals.import_failure.emit(("Unknown object", str(e)))
+ import_signals.import_failure.emit((_("Unknown object"), str(e)))
except errors.StrategyError as e:
# Excel import failed because extra databases were found, relink
log.error(
@@ -985,9 +1028,11 @@ def run_local_import(self):
except errors.LinkingFailed as e:
# Excel import failed after asking user to relink.
error = (
- "Unlinked exchanges",
- "Some exchanges could not be linked in databases: '[{}]'".format(
- ", ".join(e.args[1])
+ _("Unlinked exchanges"),
+ _(
+ "Some exchanges could not be linked in databases: "
+ "'[{databases}]'",
+ databases=", ".join(e.args[1]),
),
e.args[0],
)
@@ -996,7 +1041,7 @@ def run_local_import(self):
)
except ValueError as e:
# Relinking of BW2Package strategy has failed.
- import_signals.import_failure.emit(("Relinking failed", e.args[0]))
+ import_signals.import_failure.emit((_("Relinking failed"), e.args[0]))
def delete_canceled_db(self):
if self.db_name in bd.databases:
@@ -1009,17 +1054,19 @@ class EcoinventLoginPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
- self.setTitle("Login")
- self.setSubTitle("Login with your ecoinvent credentials to authorize the download")
+ self.setTitle(_("Login"))
+ self.setSubTitle(
+ _("Login with your ecoinvent credentials to authorize the download")
+ )
# create username field
self.username = QtWidgets.QLineEdit()
- self.username.setPlaceholderText('ecoinvent username')
+ self.username.setPlaceholderText(_("ecoinvent username"))
self.registerField("username*", self.username)
# create password field and set hidden
self.password = QtWidgets.QLineEdit()
- self.password.setPlaceholderText('ecoinvent password'),
+ self.password.setPlaceholderText(_("ecoinvent password"))
self.password.setEchoMode(QtWidgets.QLineEdit.Password)
self.registerField("password*", self.password)
@@ -1056,11 +1103,13 @@ def validatePage(self):
# in case of 401: Unauthorized, we prompt for a retry of logon
if e.response.status_code == 401:
- self.message.setText("Invalid username and/or password, please try again.")
+ self.message.setText(
+ _("Invalid username and/or password, please try again.")
+ )
return False
# else, other HTTPError, try again later maybe? Raise exception for logging
else:
- self.message.setText("Unknown connection error, try again later.")
+ self.message.setText(_("Unknown connection error, try again later."))
raise e
# in case of success, set the settings for permanent use
@@ -1091,11 +1140,11 @@ def run(self):
msg = str(e)
cs = ei.CachedStorage()
if len(cs.catalogue) > 0:
- msg += (
+ msg += _(
"\n\nIf you work offline you can use your previously downloaded databases"
- + " via the archive option of the import wizard."
+ " via the archive option of the import wizard."
)
- import_signals.connection_problem.emit(("Unexpected error", msg))
+ import_signals.connection_problem.emit((_("Unexpected error"), msg))
else:
import_signals.login_success.emit(login_success)
finally:
@@ -1108,7 +1157,7 @@ def __init__(self, parent=None):
super().__init__(parent)
self.wizard: "DatabaseImportWizard" = self.parent()
self.description_label = QtWidgets.QLabel(
- "Choose ecoinvent version and system model:"
+ _("Choose ecoinvent version and system model:")
)
self.db_dict = None
self.requires_database_creation = False
@@ -1120,9 +1169,9 @@ def __init__(self, parent=None):
layout = QtWidgets.QGridLayout()
layout.addWidget(self.description_label, 0, 0, 1, 3)
- layout.addWidget(QtWidgets.QLabel("Version: "), 1, 0)
+ layout.addWidget(QtWidgets.QLabel(_("Version: ")), 1, 0)
layout.addWidget(self.version_combobox, 1, 1, 1, 2)
- layout.addWidget(QtWidgets.QLabel("System model: "), 2, 0)
+ layout.addWidget(QtWidgets.QLabel(_("System model: ")), 2, 0)
layout.addWidget(self.system_model_combobox, 2, 1, 1, 2)
self.setLayout(layout)
@@ -1146,8 +1195,8 @@ def initializePage(self):
# Raise an error if the version_combobox is empty
import_signals.connection_problem.emit(
(
- "Cannot find files",
- "Cannot find any valid data with the given login credentials",
+ _("Cannot find files"),
+ _("Cannot find any valid data with the given login credentials"),
)
)
self.wizard.back()
@@ -1183,14 +1232,14 @@ def __init__(self, parent=None):
self.path = QtWidgets.QLineEdit()
self.path.setReadOnly(True)
self.path.textChanged.connect(self.changed)
- self.path_btn = QtWidgets.QPushButton("Browse")
+ self.path_btn = QtWidgets.QPushButton(_("Browse"))
self.path_btn.clicked.connect(self.browse)
self.complete = False
- box = QtWidgets.QGroupBox("Import local database file:")
+ box = QtWidgets.QGroupBox(_("Import local database file:"))
grid_layout = QtWidgets.QGridLayout()
layout = QtWidgets.QVBoxLayout()
- grid_layout.addWidget(QtWidgets.QLabel("Path to file*"), 0, 0, 1, 1)
+ grid_layout.addWidget(QtWidgets.QLabel(_("Path to file*")), 0, 0, 1, 1)
grid_layout.addWidget(self.path, 0, 1, 1, 2)
grid_layout.addWidget(self.path_btn, 0, 3, 1, 1)
box.setLayout(grid_layout)
@@ -1209,8 +1258,8 @@ def nextId(self):
return DatabaseImportWizard.DB_NAME
def browse(self) -> None:
- path, _ = QtWidgets.QFileDialog.getOpenFileName(
- parent=self, caption="Select a valid BW2Package file"
+ path, _selected_filter = QtWidgets.QFileDialog.getOpenFileName(
+ parent=self, caption=_("Select a valid BW2Package file")
)
if path:
self.path.setText(path)
@@ -1222,8 +1271,11 @@ def changed(self):
if exists and not valid:
import_signals.import_failure.emit(
(
- "Invalid extension",
- "Expecting 'local' import database file to have '.bw2package' extension",
+ _("Invalid extension"),
+ _(
+ "Expecting 'local' import database file to have "
+ "'.bw2package' extension"
+ ),
)
)
self.complete = all([exists, valid])
@@ -1240,14 +1292,14 @@ def __init__(self, parent=None):
self.path = QtWidgets.QLineEdit()
self.path.setReadOnly(True)
self.path.textChanged.connect(self.changed)
- self.path_btn = QtWidgets.QPushButton("Browse")
+ self.path_btn = QtWidgets.QPushButton(_("Browse"))
self.path_btn.clicked.connect(self.browse)
self.complete = False
- option_box = QtWidgets.QGroupBox("Import excel database file:")
+ option_box = QtWidgets.QGroupBox(_("Import excel database file:"))
grid_layout = QtWidgets.QGridLayout()
layout = QtWidgets.QVBoxLayout()
- grid_layout.addWidget(QtWidgets.QLabel("Path to file*"), 0, 0, 1, 1)
+ grid_layout.addWidget(QtWidgets.QLabel(_("Path to file*")), 0, 0, 1, 1)
grid_layout.addWidget(self.path, 0, 1, 1, 2)
grid_layout.addWidget(self.path_btn, 0, 3, 1, 1)
option_box.setLayout(grid_layout)
@@ -1267,10 +1319,10 @@ def nextId(self):
@Slot(name="browseFile")
def browse(self) -> None:
- path, _ = QtWidgets.QFileDialog.getOpenFileName(
+ path, _selected_filter = QtWidgets.QFileDialog.getOpenFileName(
parent=self,
- caption="Select an excel database file",
- filter="Excel (*.xlsx);; All Files (*.*)",
+ caption=_("Select an excel database file"),
+ filter=_("Excel (*.xlsx);; All Files (*.*)"),
)
if path:
self.path.setText(path)
@@ -1283,8 +1335,8 @@ def changed(self) -> None:
if exists and not valid:
import_signals.import_failure.emit(
(
- "Invalid extension",
- "Expecting excel file to have '.xls' or '.xlsx' extension",
+ _("Invalid extension"),
+ _("Expecting excel file to have '.xls' or '.xlsx' extension"),
)
)
self.complete = all([exists, valid])
@@ -1442,8 +1494,8 @@ def login(self) -> (bool, typing.Optional[typing.Tuple[str, str]]):
) as e:
login_success = False
error_message = (
- "Connection Problem",
- "The request timed out, please check your internet connection!",
+ _("Connection Problem"),
+ _("The request timed out, please check your internet connection!"),
)
except requests.exceptions.HTTPError as e:
login_success = False
@@ -1454,9 +1506,12 @@ def login(self) -> (bool, typing.Optional[typing.Tuple[str, str]]):
f"response: {e.response.text}",
)
error_message = (
- "Unexpected Problem",
- "An unexpected error occurred, please try again status code %d"
- % e.response.status_code,
+ _("Unexpected Problem"),
+ _(
+ "An unexpected error occurred, please try again status code "
+ "{status_code}",
+ status_code=e.response.status_code,
+ ),
)
return login_success, error_message
diff --git a/activity_browser/ui/wizards/plugins_manager_wizard.py b/activity_browser/ui/wizards/plugins_manager_wizard.py
index 08788c21e..4d199d397 100644
--- a/activity_browser/ui/wizards/plugins_manager_wizard.py
+++ b/activity_browser/ui/wizards/plugins_manager_wizard.py
@@ -2,6 +2,7 @@
from PySide2 import QtCore, QtWidgets
from PySide2.QtCore import Qt, Slot
+from ...i18n import _
from ...signals import signals
from ...ui.style import header
from ...ui.tables import PluginsTable
@@ -10,7 +11,7 @@
class PluginsManagerWizard(QtWidgets.QWizard):
def __init__(self, key: tuple, parent=None):
super().__init__(parent)
- self.setWindowTitle("Plugins manager")
+ self.setWindowTitle(_("Plugins manager"))
self.manager_page = ManagePluginsPage(self)
self.pages = [self.manager_page]
@@ -37,7 +38,7 @@ def __init__(self, parent=None):
self.setFinalPage(True)
def initializePage(self):
- self.wizard.setButtonText(QtWidgets.QWizard.FinishButton, "Confirm")
+ self.wizard.setButtonText(QtWidgets.QWizard.FinishButton, _("Confirm"))
self.wizard.button(QtWidgets.QWizard.FinishButton).clicked.connect(
self.confirm_plugins
)
@@ -74,7 +75,7 @@ def _construct_layout(self):
header_widget = QtWidgets.QWidget()
header_layout = QtWidgets.QHBoxLayout()
header_layout.setAlignment(QtCore.Qt.AlignLeft)
- header_layout.addWidget(header("Available plugins:"))
+ header_layout.addWidget(header(_("Available plugins:")))
header_widget.setLayout(header_layout)
# Overall Layout
diff --git a/activity_browser/ui/wizards/project_setup_wizard.py b/activity_browser/ui/wizards/project_setup_wizard.py
index f917c5a13..7f7c6cb6d 100644
--- a/activity_browser/ui/wizards/project_setup_wizard.py
+++ b/activity_browser/ui/wizards/project_setup_wizard.py
@@ -7,6 +7,7 @@
from activity_browser.mod.bw2io.ecoinvent import ab_import_ecoinvent_release
from activity_browser.utils import sort_semantic_versions
from activity_browser.info import __ei_versions__
+from activity_browser.i18n import _
class ProjectSetupWizard(QtWidgets.QWizard):
@@ -26,7 +27,7 @@ def __init__(self, parent=None):
self.setOption(self.NoCancelButton, False)
# setting window options
- self.setWindowTitle("Project Setup")
+ self.setWindowTitle(_("Project Setup"))
self.setWindowModality(QtCore.Qt.ApplicationModal)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.setWindowFlags(QtCore.Qt.Sheet)
@@ -48,12 +49,12 @@ class ChooseSetupPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
- self.setTitle("Setup type")
- self.setSubTitle("Choose how you want to set up you project")
+ self.setTitle(_("Setup type"))
+ self.setSubTitle(_("Choose how you want to set up you project"))
# radio buttons for the setup mode
radio_1 = QtWidgets.QRadioButton("Biosphere3")
- radio_2 = QtWidgets.QRadioButton("ecoinvent and Biosphere3")
+ radio_2 = QtWidgets.QRadioButton(_("ecoinvent and Biosphere3"))
radio_1.setChecked(True)
# join the buttons in a buttongroup, id of the button is the id of the next page for that choice
@@ -84,8 +85,8 @@ class DefaultVersionPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
- self.setTitle("Choose version")
- self.setSubTitle("Choose biosphere version")
+ self.setTitle(_("Choose version"))
+ self.setSubTitle(_("Choose biosphere version"))
# set combobox for version selection
self.versions = QtWidgets.QComboBox(self)
@@ -107,17 +108,19 @@ class EcoInventLoginPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
- self.setTitle("Login")
- self.setSubTitle("Login with your ecoinvent credentials to authorize the download")
+ self.setTitle(_("Login"))
+ self.setSubTitle(
+ _("Login with your ecoinvent credentials to authorize the download")
+ )
# create username field
self.username = QtWidgets.QLineEdit()
- self.username.setPlaceholderText('ecoinvent username')
+ self.username.setPlaceholderText(_("ecoinvent username"))
self.registerField("username*", self.username)
# create password field and set hidden
self.password = QtWidgets.QLineEdit()
- self.password.setPlaceholderText('ecoinvent password'),
+ self.password.setPlaceholderText(_("ecoinvent password"))
self.password.setEchoMode(QtWidgets.QLineEdit.Password)
self.registerField("password*", self.password)
@@ -153,16 +156,22 @@ def validatePage(self):
# in case of 401: Unauthorized, we prompt for a retry of logon
if e.response.status_code == 401:
- self.message.setText("Invalid username and/or password, please try again.")
+ self.message.setText(
+ _("Invalid username and/or password, please try again.")
+ )
return False
# else, other HTTPError, try again later maybe? Raise exception for logging
else:
- self.message.setText("Unknown connection error, try again later.")
+ self.message.setText(
+ _("Unknown connection error, try again later.")
+ )
raise e
except requests.exceptions.ConnectionError:
QtWidgets.QApplication.restoreOverrideCursor()
- self.message.setText("Cannot connect to the internet, please try again later.")
+ self.message.setText(
+ _("Cannot connect to the internet, please try again later.")
+ )
return False
except Exception as e:
@@ -184,8 +193,8 @@ class EcoInventVersionPage(QtWidgets.QWizardPage):
def __init__(self, parent=None):
super().__init__(parent)
- self.setTitle("Choose version")
- self.setSubTitle("Choose ecoinvent version and system model")
+ self.setTitle(_("Choose version"))
+ self.setSubTitle(_("Choose ecoinvent version and system model"))
# set comboboxes for version and model selections
self.versions = QtWidgets.QComboBox(self)
@@ -231,8 +240,8 @@ def __init__(self, parent=None):
self.install_thread = None # will be ei or default install thread
- self.setTitle("Setting up")
- self.setSubTitle("Setting up your project")
+ self.setTitle(_("Setting up"))
+ self.setSubTitle(_("Setting up your project"))
# setup progressbar
self.progress = QtWidgets.QProgressBar()
@@ -259,7 +268,7 @@ def initializePage(self):
self.install_thread.status.connect(self.status_update)
self.install_thread.finished.connect(self.completeChanged.emit)
- self.install_thread.finished.connect(lambda: self.status_update(100, "Done"))
+ self.install_thread.finished.connect(lambda: self.status_update(100, _("Done")))
self.install_thread.start()
def isComplete(self):
@@ -284,4 +293,3 @@ class EcoinventInstallThread(ABThread):
def run_safely(self):
ab_import_ecoinvent_release(self.parent().field("version"), self.parent().field("model"))
-
diff --git a/activity_browser/ui/wizards/settings_wizard.py b/activity_browser/ui/wizards/settings_wizard.py
index 27a4f8ff7..be40ac534 100644
--- a/activity_browser/ui/wizards/settings_wizard.py
+++ b/activity_browser/ui/wizards/settings_wizard.py
@@ -6,6 +6,7 @@
from PySide2 import QtCore, QtWidgets
from activity_browser import ab_settings
+from activity_browser.i18n import _, language_choices
from activity_browser.mod.bw2data import projects
log = getLogger(__name__)
@@ -16,7 +17,7 @@ def __init__(self, parent=None):
self.last_project = projects.current
self.last_bwdir = projects.base_dir
- self.setWindowTitle("Activity Browser Settings")
+ self.setWindowTitle(_("Activity Browser Settings"))
self.settings_page = SettingsPage(self)
self.addPage(self.settings_page)
self.show()
@@ -41,6 +42,13 @@ def save_settings(self):
ab_settings.startup_project = new_startup_project
log.info(f"Saved startup project as: {new_startup_project}")
+ # Language display names are deliberately not persisted. The stable
+ # item data is applied at the next application start.
+ language = self.settings_page.language_combo.currentData()
+ if language != ab_settings.language:
+ ab_settings.language = language
+ log.info(f"Saved interface language as: {language} (restart required)")
+
ab_settings.write_settings()
projects.switch_dir(field)
@@ -64,10 +72,10 @@ def __init__(self, parent=None):
self.bwdir_variables = set()
self.bwdir = QtWidgets.QComboBox()
- self.bwdir_browse_button = QtWidgets.QPushButton("Browse")
- self.bwdir_remove_button = QtWidgets.QPushButton("Remove")
+ self.bwdir_browse_button = QtWidgets.QPushButton(_("Browse"))
+ self.bwdir_remove_button = QtWidgets.QPushButton(_("Remove"))
self.update_combobox(self.bwdir, ab_settings.custom_bw_dir)
- self.restore_defaults_button = QtWidgets.QPushButton("Restore defaults")
+ self.restore_defaults_button = QtWidgets.QPushButton(_("Restore defaults"))
self.bwdir_name = QtWidgets.QLineEdit(self.bwdir.currentText())
self.registerField("current_bw_dir", self.bwdir_name)
@@ -81,27 +89,35 @@ def __init__(self, parent=None):
# light/dark theme
self.theme_combo = QtWidgets.QComboBox()
- self.theme_combo.addItems([
- "Light theme",
- "Dark theme compatibility"
- ])
- self.theme_combo.setCurrentText(ab_settings.theme)
- self.registerField(
- "theme_cbox", self.theme_combo, "currentText"
- )
+ for theme_code in ("Light theme", "Dark theme compatibility"):
+ self.theme_combo.addItem(_(theme_code), theme_code)
+ theme_index = self.theme_combo.findData(ab_settings.theme)
+ self.theme_combo.setCurrentIndex(max(theme_index, 0))
+
+ # UI labels are translated, while itemData contains the stable codes
+ # written to ABsettings.json.
+ self.language_combo = QtWidgets.QComboBox()
+ self.language_combo.setObjectName("language_combo")
+ for language_code, display_name in language_choices():
+ self.language_combo.addItem(display_name, language_code)
+ language_index = self.language_combo.findData(ab_settings.language)
+ self.language_combo.setCurrentIndex(max(language_index, 0))
# Startup options
- self.startup_groupbox = QtWidgets.QGroupBox("Startup Options")
+ self.startup_groupbox = QtWidgets.QGroupBox(_("Startup Options"))
self.startup_layout = QtWidgets.QGridLayout()
- self.startup_layout.addWidget(QtWidgets.QLabel("Brightway Dir: "), 0, 0)
+ self.startup_layout.addWidget(QtWidgets.QLabel(_("Brightway Dir: ")), 0, 0)
self.startup_layout.addWidget(self.bwdir, 0, 1)
self.startup_layout.addWidget(self.bwdir_browse_button, 0, 2)
self.startup_layout.addWidget(self.bwdir_remove_button, 0, 3)
- self.startup_layout.addWidget(QtWidgets.QLabel("Startup Project: "), 1, 0)
+ self.startup_layout.addWidget(QtWidgets.QLabel(_("Startup Project: ")), 1, 0)
self.startup_layout.addWidget(self.startup_project_combobox, 1, 1)
- self.startup_layout.addWidget(QtWidgets.QLabel("Theme: "), 2, 0)
+ self.startup_layout.addWidget(QtWidgets.QLabel(_("Theme: ")), 2, 0)
self.startup_layout.addWidget(self.theme_combo, 2, 1)
- self.startup_layout.addWidget(QtWidgets.QLabel("(Requires restart)"), 2, 2)
+ self.startup_layout.addWidget(QtWidgets.QLabel(_("(Requires restart)")), 2, 2)
+ self.startup_layout.addWidget(QtWidgets.QLabel(_("Language: ")), 3, 0)
+ self.startup_layout.addWidget(self.language_combo, 3, 1)
+ self.startup_layout.addWidget(QtWidgets.QLabel(_("(Requires restart)")), 3, 2)
self.startup_groupbox.setLayout(self.startup_layout)
@@ -111,14 +127,15 @@ def __init__(self, parent=None):
self.layout.addWidget(self.restore_defaults_button)
self.setLayout(self.layout)
self.setFinalPage(True)
- self.setButtonText(QtWidgets.QWizard.FinishButton, "Save")
+ self.setButtonText(QtWidgets.QWizard.FinishButton, _("Save"))
# signals
self.startup_project_combobox.currentIndexChanged.connect(self.changed)
self.bwdir_browse_button.clicked.connect(self.bwdir_browse)
self.bwdir_remove_button.clicked.connect(self.bwdir_remove)
self.bwdir.currentTextChanged.connect(self.bwdir_change)
- self.theme_combo.currentTextChanged.connect(self.theme_change)
+ self.theme_combo.currentIndexChanged.connect(self.theme_change)
+ self.language_combo.currentIndexChanged.connect(self.changed)
self.restore_defaults_button.clicked.connect(self.restore_defaults)
def bw_projects(self, path: str):
@@ -135,6 +152,10 @@ def restore_defaults(self):
self.startup_project_combobox.setCurrentText(
ab_settings.get_default_project_name()
)
+ language_index = self.language_combo.findData(
+ ab_settings.get_default_settings()["language"]
+ )
+ self.language_combo.setCurrentIndex(language_index)
def bwdir_remove(self):
"""
@@ -143,10 +164,12 @@ def bwdir_remove(self):
"""
hard_deletion = QtWidgets.QMessageBox.question(
self,
- "Delete Brightway2 directory?",
- "This action will remove the local information only, click"
- "'Yes' to remove\nthe projects. Data on the \"disk\" will remain"
- " untouched and needs to be removed manually",
+ _("Delete Brightway2 directory?"),
+ _(
+ "This action will remove the local information only, click"
+ "'Yes' to remove\nthe projects. Data on the \"disk\" will remain"
+ " untouched and needs to be removed manually"
+ ),
QtWidgets.QMessageBox.Yes,
QtWidgets.QMessageBox.Cancel,
)
@@ -173,8 +196,9 @@ def bwdir_change(self, path: str):
"""
self.change_bw_dir(path)
- def theme_change(self, theme: str):
+ def theme_change(self, index: int):
"""Change the theme."""
+ theme = self.theme_combo.itemData(index)
if ab_settings.theme != theme:
ab_settings.theme = theme
self.changed()
@@ -187,7 +211,7 @@ def bwdir_browse(self):
bw2 environments
"""
path = QtWidgets.QFileDialog.getExistingDirectory(
- self, "Select a brightway2 database folder"
+ self, _("Select a brightway2 database folder")
)
if path:
self.change_bw_dir(os.path.normpath(path))
@@ -201,8 +225,12 @@ def change_bw_dir(self, path):
if not os.path.isfile(os.path.join(path, "projects.db")):
create_new_directory = QtWidgets.QMessageBox.question(
self,
- "New brightway data directory?",
- 'This directory does not contain any projects. \n Would you like to setup a new brightway data directory here? \n This will close the current project and create a "default" project in the new directory.',
+ _("New brightway data directory?"),
+ _(
+ "This directory does not contain any projects. \n Would you like "
+ "to setup a new brightway data directory here? \n This will close "
+ 'the current project and create a "default" project in the new directory.'
+ ),
QtWidgets.QMessageBox.Yes,
QtWidgets.QMessageBox.Cancel,
)
@@ -223,8 +251,12 @@ def change_bw_dir(self, path):
# ask user if to switch directory (which will update the project combobox correctly)
reply = QtWidgets.QMessageBox.question(
self,
- "Continue?",
- 'Would you like to switch to this directory now? \nThis will close your currently opened project. \nClick "Yes" to be able to choose the startup project.',
+ _("Continue?"),
+ _(
+ "Would you like to switch to this directory now? \nThis will close "
+ 'your currently opened project. \nClick "Yes" to be able to choose '
+ "the startup project."
+ ),
QtWidgets.QMessageBox.Yes,
QtWidgets.QMessageBox.No,
)
@@ -291,9 +323,11 @@ def update_combobox(self, box: QtWidgets.QComboBox, labels: list) -> None:
return
QtWidgets.QMessageBox.warning(
self,
- "Discrepancy in the ABsettings.json file",
- "The value provided for the current brightway directory does not exist\n"
- "in the available list of directories. Please check the settings file.",
+ _("Discrepancy in the ABsettings.json file"),
+ _(
+ "The value provided for the current brightway directory does not exist\n"
+ "in the available list of directories. Please check the settings file."
+ ),
QtWidgets.QMessageBox.Ok,
)
diff --git a/activity_browser/ui/wizards/uncertainty.py b/activity_browser/ui/wizards/uncertainty.py
index 854dc5922..b4966583e 100644
--- a/activity_browser/ui/wizards/uncertainty.py
+++ b/activity_browser/ui/wizards/uncertainty.py
@@ -7,6 +7,7 @@
from stats_arrays.distributions import *
from activity_browser import actions, application
+from activity_browser.i18n import _
from ...bwutils import PedigreeMatrix, get_uncertainty_interface
from ...bwutils.uncertainty import EMPTY_UNCERTAINTY
@@ -30,6 +31,7 @@ class UncertaintyWizard(QtWidgets.QWizard):
def __init__(self, unc_object: object, parent=None):
super().__init__(parent)
+ self.setWindowTitle(_("Uncertainty"))
self.obj = get_uncertainty_interface(unc_object)
self.using_pedigree = False
@@ -144,13 +146,13 @@ def amount_mean_test(self) -> None:
elif uc_type in self.type.mean_is_calculated:
mean = self.type.calculate_mean
if not np.isclose(self.obj.amount, mean) and uc_type not in no_change:
- msg = (
+ msg = _(
"Do you want to update the 'amount' field to match mean?"
- "\nAmount: {}\tMean: {}".format(self.obj.amount, mean)
- )
+ "\nAmount: {amount}\tMean: {mean}"
+ ).format(amount=self.obj.amount, mean=mean)
choice = QtWidgets.QMessageBox.question(
self,
- "Amount differs from mean",
+ _("Amount differs from mean"),
msg,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.Yes,
@@ -165,7 +167,7 @@ def amount_mean_test(self) -> None:
except Exception as e:
QtWidgets.QMessageBox.warning(
application.main_window,
- "Could not save changes",
+ _("Could not save changes"),
str(e),
QtWidgets.QMessageBox.Ok,
QtWidgets.QMessageBox.Ok,
@@ -196,22 +198,29 @@ def __init__(self, parent=None):
}
# Selection of uncertainty distribution.
- box1 = QtWidgets.QGroupBox("Select the uncertainty distribution")
+ box1 = QtWidgets.QGroupBox(_("Select the uncertainty distribution"))
box1.setStyleSheet(style_group_box.border_title)
self.distribution = QtWidgets.QComboBox(box1)
- self.distribution.addItems([ud.description for ud in uncertainty.choices])
+ # Descriptions are fixed UI metadata from stats_arrays. Translation
+ # changes only the displayed text; the combobox index remains the
+ # distribution ID used by calculations.
+ self.distribution.addItems(
+ [_(ud.description) for ud in uncertainty.choices]
+ )
self.distribution.currentIndexChanged.connect(self.distribution_selection)
self.registerField("uncertainty type", self.distribution, "currentIndex")
- self.pedigree = QtWidgets.QPushButton("Use pedigree")
+ self.pedigree = QtWidgets.QPushButton(_("Use pedigree"))
self.pedigree.clicked.connect(self.pedigree_page)
box_layout = QtWidgets.QGridLayout()
- box_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0, 2, 1)
+ box_layout.addWidget(QtWidgets.QLabel(_("Distribution:")), 0, 0, 2, 1)
box_layout.addWidget(self.distribution, 0, 1, 2, 2)
box_layout.addWidget(self.pedigree, 0, 3, 2, 1)
box1.setLayout(box_layout)
# Set values for selected uncertainty distribution.
- self.field_box = QtWidgets.QGroupBox("Fill out or change required parameters")
+ self.field_box = QtWidgets.QGroupBox(
+ _("Fill out or change required parameters")
+ )
self.field_box.setStyleSheet(style_group_box.border_title)
self.locale = QtCore.QLocale(
QtCore.QLocale.English, QtCore.QLocale.UnitedStates
@@ -224,32 +233,32 @@ def __init__(self, parent=None):
self.loc.textEdited.connect(self.balance_mean_with_loc)
self.loc.textEdited.connect(self.check_negative)
self.loc.textEdited.connect(self.generate_plot)
- self.loc_label = QtWidgets.QLabel("Loc:")
+ self.loc_label = QtWidgets.QLabel(_("Loc:"))
self.mean = QtWidgets.QLineEdit()
self.mean.setValidator(self.validator)
self.mean.textEdited.connect(self.balance_loc_with_mean)
self.mean.textEdited.connect(self.check_negative)
self.mean.textEdited.connect(self.generate_plot)
- self.mean_label = QtWidgets.QLabel("Mean:")
- self.blocked_label = QtWidgets.QLabel("Mean:")
+ self.mean_label = QtWidgets.QLabel(_("Mean:"))
+ self.blocked_label = QtWidgets.QLabel(_("Mean:"))
self.blocked_mean = QtWidgets.QLineEdit("nan")
self.blocked_mean.setDisabled(True)
self.scale = QtWidgets.QLineEdit()
self.scale.setValidator(self.validator)
self.scale.textEdited.connect(self.generate_plot)
- self.scale_label = QtWidgets.QLabel("Sigma/scale:")
+ self.scale_label = QtWidgets.QLabel(_("Sigma/scale:"))
self.shape = QtWidgets.QLineEdit()
self.shape.setValidator(self.validator)
self.shape.textEdited.connect(self.generate_plot)
- self.shape_label = QtWidgets.QLabel("Shape:")
+ self.shape_label = QtWidgets.QLabel(_("Shape:"))
self.minimum = QtWidgets.QLineEdit()
self.minimum.setValidator(self.validator)
self.minimum.textEdited.connect(self.generate_plot)
- self.min_label = QtWidgets.QLabel("Minimum:")
+ self.min_label = QtWidgets.QLabel(_("Minimum:"))
self.maximum = QtWidgets.QLineEdit()
self.maximum.setValidator(self.validator)
self.maximum.textEdited.connect(self.generate_plot)
- self.max_label = QtWidgets.QLabel("Maximum:")
+ self.max_label = QtWidgets.QLabel(_("Maximum:"))
self.negative = QtWidgets.QRadioButton(self)
self.negative.setChecked(False)
self.negative.setHidden(True)
@@ -334,15 +343,15 @@ def distribution_loc_label(self) -> str:
into the 'loc' field.
"""
if self.dist.id == LognormalUncertainty.id:
- return "Loc (ln(mean)):"
+ return _("Loc (ln(mean)):")
elif self.dist.id == TriangularUncertainty.id:
- return "Mode:"
+ return _("Mode:")
elif self.dist.id == BetaUncertainty.id:
- return "Loc / alpha:"
+ return _("Loc / alpha:")
elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}:
- return "Loc / offset:"
+ return _("Loc / offset:")
else:
- return "Mean:"
+ return _("Mean:")
@property
def calculate_mean(self) -> float:
@@ -520,7 +529,9 @@ def __init__(self, parent=None):
self.setFinalPage(True)
self.matrix = None
- self.field_box = QtWidgets.QGroupBox("Fill out or change required parameters")
+ self.field_box = QtWidgets.QGroupBox(
+ _("Fill out or change required parameters")
+ )
self.field_box.setStyleSheet(style_group_box.border_title)
self.locale = QtCore.QLocale(
QtCore.QLocale.English, QtCore.QLocale.UnitedStates
@@ -539,63 +550,87 @@ def __init__(self, parent=None):
self.mean.textEdited.connect(self.check_negative)
self.mean.textEdited.connect(self.check_complete)
box_layout = QtWidgets.QGridLayout()
- box_layout.addWidget(QtWidgets.QLabel("Loc (ln(mean)):"), 0, 0)
+ box_layout.addWidget(QtWidgets.QLabel(_("Loc (ln(mean)):")), 0, 0)
box_layout.addWidget(self.loc, 0, 1)
- box_layout.addWidget(QtWidgets.QLabel("Mean:"), 0, 3)
+ box_layout.addWidget(QtWidgets.QLabel(_("Mean:")), 0, 3)
box_layout.addWidget(self.mean, 0, 4)
self.field_box.setLayout(box_layout)
- box = QtWidgets.QGroupBox("Select pedigree values")
+ box = QtWidgets.QGroupBox(_("Select pedigree values"))
box.setStyleSheet(style_group_box.border_title)
self.reliable = QtWidgets.QComboBox(box)
self.reliable.addItems(
[
- "1) Verified data based on measurements",
- "2) Verified data partly based on assumptions",
- "3) Non-verified data partly based on qualified measurements",
- "4) Qualified estimate",
- "5) Non-qualified estimate",
+ _("1) Verified data based on measurements"),
+ _("2) Verified data partly based on assumptions"),
+ _("3) Non-verified data partly based on qualified estimates"),
+ _("4) Qualified estimate"),
+ _("5) Non-qualified estimate"),
]
)
self.complete = QtWidgets.QComboBox(box)
self.complete.addItems(
[
- "1) Representative relevant data from all sites, over an adequate period",
- "2) Representative relevant data from >50% sites, over an adequate period",
- "3) Representative relevant data from <50% sites OR >50%, but over shorter period",
- "4) Representative relevant data from one site OR some sites but over shorter period",
- "5) Representativeness unknown",
+ _(
+ "1) Representative relevant data from all sites, over an "
+ "adequate period"
+ ),
+ _(
+ "2) Representative relevant data from >50% sites, over an "
+ "adequate period"
+ ),
+ _(
+ "3) Representative relevant data from <50% sites OR >50%, "
+ "but over shorter period"
+ ),
+ _(
+ "4) Representative relevant data from one site OR some sites "
+ "but over shorter period"
+ ),
+ _("5) Representativeness unknown"),
]
)
self.temporal = QtWidgets.QComboBox(box)
self.temporal.addItems(
[
- "1) Data less than 3 years old",
- "2) Data less than 6 years old",
- "3) Data less than 10 years old",
- "4) Data less than 15 years old",
- "5) Data age unknown or more than 15 years old",
+ _("1) Data less than 3 years old"),
+ _("2) Data less than 6 years old"),
+ _("3) Data less than 10 years old"),
+ _("4) Data less than 15 years old"),
+ _("5) Data age unknown or more than 15 years old"),
]
)
self.geographical = QtWidgets.QComboBox(box)
self.geographical.addItems(
[
- "1) Data from area under study",
- "2) Average data from larger area in which area under study is included",
- "3) Data from area with similar production conditions",
- "4) Data from area with slightly similar production conditions",
- "5) Data from unknown OR distinctly different area",
+ _("1) Data from area under study"),
+ _(
+ "2) Average data from larger area in which area under study "
+ "is included"
+ ),
+ _("3) Data from area with similar production conditions"),
+ _("4) Data from area with slightly similar production conditions"),
+ _("5) Data from unknown OR distinctly different area"),
]
)
self.technological = QtWidgets.QComboBox(box)
self.technological.addItems(
[
- "1) Data from enterprises, processes and materials under study",
- "2) Data from processes and materials under study, different enterprise",
- "3) Data from processes and materials under study from different technology",
- "4) Data on related processes and materials",
- "5) Data on related processes on lab scale OR from different technology",
+ _("1) Data from enterprises, processes and materials under study"),
+ _(
+ "2) Data from processes and materials under study, different "
+ "enterprise"
+ ),
+ _(
+ "3) Data from processes and materials under study from "
+ "different technology"
+ ),
+ _("4) Data on related processes and materials"),
+ _(
+ "5) Data on related processes on lab scale OR from different "
+ "technology"
+ ),
]
)
self.reliable.currentIndexChanged.connect(self.check_complete)
@@ -605,16 +640,20 @@ def __init__(self, parent=None):
self.technological.currentIndexChanged.connect(self.check_complete)
box_layout = QtWidgets.QGridLayout()
- box_layout.addWidget(QtWidgets.QLabel("Reliability"), 0, 0, 2, 2)
+ box_layout.addWidget(QtWidgets.QLabel(_("Reliability")), 0, 0, 2, 2)
box_layout.addWidget(self.reliable, 0, 2, 2, 3)
- box_layout.addWidget(QtWidgets.QLabel("Completeness"), 2, 0, 2, 2)
+ box_layout.addWidget(QtWidgets.QLabel(_("Completeness")), 2, 0, 2, 2)
box_layout.addWidget(self.complete, 2, 2, 2, 3)
- box_layout.addWidget(QtWidgets.QLabel("Temporal correlation"), 4, 0, 2, 2)
+ box_layout.addWidget(
+ QtWidgets.QLabel(_("Temporal correlation")), 4, 0, 2, 2
+ )
box_layout.addWidget(self.temporal, 4, 2, 2, 3)
- box_layout.addWidget(QtWidgets.QLabel("Geographical correlation"), 6, 0, 2, 2)
+ box_layout.addWidget(
+ QtWidgets.QLabel(_("Geographical correlation")), 6, 0, 2, 2
+ )
box_layout.addWidget(self.geographical, 6, 2, 2, 3)
box_layout.addWidget(
- QtWidgets.QLabel("Further technological correlation"), 8, 0, 2, 2
+ QtWidgets.QLabel(_("Further technological correlation")), 8, 0, 2, 2
)
box_layout.addWidget(self.technological, 8, 2, 2, 3)
box.setLayout(box_layout)
diff --git a/activity_browser/utils.py b/activity_browser/utils.py
index 1f930c2a1..3a9545764 100644
--- a/activity_browser/utils.py
+++ b/activity_browser/utils.py
@@ -6,6 +6,7 @@
from PySide2 import QtWidgets
from activity_browser.mod import bw2data as bd
+from activity_browser.i18n import _
from .settings import ab_settings
@@ -30,11 +31,11 @@ def savefilepath(
):
"""A central function to get a safe file path."""
safe_name = bd.utils.safe_filename(default_file_name, add_hash=False)
- filepath, _ = QtWidgets.QFileDialog.getSaveFileName(
+ filepath, _selected_filter = QtWidgets.QFileDialog.getSaveFileName(
parent=None,
- caption="Choose location for saving",
+ caption=_("Choose where to save"),
dir=os.path.join(ab_settings.data_dir, safe_name),
- filter=file_filter,
+ filter=_(file_filter),
)
return filepath
diff --git a/setup.py b/setup.py
index c66b77882..794a37420 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,18 @@
root_dir = os.path.dirname(__file__)
if root_dir:
os.chdir(root_dir)
-accepted_filetypes = (".html", ".png", ".svg", ".js", ".css", ".txt", ".zip", ".md")
+accepted_filetypes = (
+ ".html",
+ ".png",
+ ".svg",
+ ".js",
+ ".css",
+ ".txt",
+ ".zip",
+ ".md",
+ ".json",
+ ".qm",
+)
for dirpath, dirnames, filenames in os.walk("activity_browser"):
# Ignore dirnames that start with '.'
@@ -30,6 +41,9 @@
name="activity-browser",
version=version,
packages=packages,
+ package_data={
+ "activity_browser.translations": ["*.json", "*.qm", "*/*.json", "*/*.qm"]
+ },
include_package_data=True,
author="Bernhard Steubing",
author_email="b.steubing@cml.leidenuniv.nl",
diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py
index 2161eeeb2..d76fc571a 100644
--- a/tests/actions/test_activity_actions.py
+++ b/tests/actions/test_activity_actions.py
@@ -3,6 +3,7 @@
from PySide2 import QtWidgets
from activity_browser import actions
+from activity_browser.layouts.panels.panel import TabId
from activity_browser.mod.bw2data import Database
from activity_browser.ui.widgets.dialog import (ActivityLinkingDialog,
LocationLinkingDialog)
@@ -85,7 +86,7 @@ def test_activity_duplicate_to_loc(ab_app, monkeypatch):
def test_activity_graph(ab_app):
key = ("activity_tests", "3fcde3e3bf424e97b32cf29347ac7f33")
- panel = ab_app.main_window.right_panel.tabs["Graph Explorer"]
+ panel = ab_app.main_window.right_panel.tabs[TabId.GRAPH_EXPLORER]
assert bd.projects.current == "default"
assert bd.get_activity(key)
@@ -113,7 +114,7 @@ def test_activity_new(ab_app, monkeypatch):
def test_activity_open(ab_app):
key = ("activity_tests", "3fcde3e3bf424e97b32cf29347ac7f33")
- panel = ab_app.main_window.right_panel.tabs["Activity Details"]
+ panel = ab_app.main_window.right_panel.tabs[TabId.ACTIVITY_DETAILS]
assert bd.projects.current == "default"
assert bd.get_activity(key)
diff --git a/tests/actions/test_localization.py b/tests/actions/test_localization.py
new file mode 100644
index 000000000..148e8e4eb
--- /dev/null
+++ b/tests/actions/test_localization.py
@@ -0,0 +1,252 @@
+import ast
+import json
+import string
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+ACTIONS_DIR = ROOT / "activity_browser" / "actions"
+ACTION_FILES = tuple(sorted(ACTIONS_DIR.rglob("*.py")))
+CATALOG_FILE = ROOT / "activity_browser" / "translations" / "zh_CN" / "actions.json"
+
+
+def _catalog():
+ return json.loads(CATALOG_FILE.read_text(encoding="utf-8"))
+
+
+def _literal_value(node):
+ try:
+ return ast.literal_eval(node)
+ except (ValueError, TypeError):
+ return None
+
+
+def _class_ui_sources(tree):
+ sources = set()
+ for class_node in (
+ node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
+ ):
+ for statement in class_node.body:
+ if isinstance(statement, ast.Assign):
+ targets = statement.targets
+ value_node = statement.value
+ elif isinstance(statement, ast.AnnAssign):
+ targets = (statement.target,)
+ value_node = statement.value
+ else:
+ continue
+ for target in targets:
+ if not (
+ isinstance(target, ast.Name)
+ and target.id in {"text", "tool_tip", "tooltip"}
+ ):
+ continue
+ value = _literal_value(value_node)
+ if isinstance(value, str) and value:
+ sources.add(value)
+ return sources
+
+
+def _literal_gettext_sources(tree):
+ sources = set()
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ and node.args
+ ):
+ continue
+ value = _literal_value(node.args[0])
+ if isinstance(value, str):
+ sources.add(value)
+ return sources
+
+
+def _module_ui_collections(tree):
+ """Collect fixed UI source collections translated later in a loop."""
+
+ sources = set()
+ for statement in tree.body:
+ if not isinstance(statement, ast.Assign):
+ continue
+ if not any(
+ isinstance(target, ast.Name) and target.id.endswith("_STRINGS")
+ for target in statement.targets
+ ):
+ continue
+ value = _literal_value(statement.value)
+ if isinstance(value, (tuple, list)):
+ sources.update(item for item in value if isinstance(item, str))
+ return sources
+
+
+def test_action_class_and_direct_ui_sources_are_in_catalog():
+ sources = set()
+ class_sources = set()
+ for path in ACTION_FILES:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ class_sources.update(_class_ui_sources(tree))
+ sources.update(_literal_gettext_sources(tree))
+ sources.update(_module_ui_collections(tree))
+ sources.update(class_sources)
+
+ assert len(class_sources) >= 60
+ assert sources <= _catalog().keys()
+
+
+def _untranslated_literals(node):
+ """Return alphabetic literals which are not inside a gettext call."""
+
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
+ if node.func.id == "_":
+ return []
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ return [node.value] if any(char.isalpha() for char in node.value) else []
+ literals = []
+ for child in ast.iter_child_nodes(node):
+ literals.extend(_untranslated_literals(child))
+ return literals
+
+
+def _ui_text_arguments(call):
+ if isinstance(call.func, ast.Attribute):
+ name = call.func.attr
+ elif isinstance(call.func, ast.Name):
+ name = call.func.id
+ else:
+ return []
+
+ if name in {"warning", "information", "critical", "question", "getText", "getItem"}:
+ return call.args[1:3]
+ if name in {"getOpenFileName", "getSaveFileName"}:
+ return [
+ keyword.value
+ for keyword in call.keywords
+ if keyword.arg in {"caption", "filter"}
+ ]
+ if name == "QProgressDialog":
+ return [
+ keyword.value for keyword in call.keywords if keyword.arg == "labelText"
+ ]
+ if name in {
+ "showMessage",
+ "setWindowTitle",
+ "setLabelText",
+ "setTitle",
+ "setSubTitle",
+ "setToolTip",
+ "setPlaceholderText",
+ }:
+ return call.args[:1]
+ if name in {"QGroupBox", "QLabel", "QRadioButton"}:
+ return call.args[:1]
+ if name == "QPushButton":
+ return call.args[1:2] if len(call.args) > 1 else call.args[:1]
+ if name == "get_combined_name":
+ # Parent is first; title and field label are UI. The remaining suffix
+ # becomes part of the new scientific method name and stays unchanged.
+ return call.args[1:3]
+ return []
+
+
+def test_direct_ui_literals_are_translated():
+ for path in ACTION_FILES:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ parents = {
+ child: parent
+ for parent in ast.walk(tree)
+ for child in ast.iter_child_nodes(parent)
+ }
+
+ def enclosing_function(node):
+ while node in parents:
+ node = parents[node]
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ return node
+ return tree
+
+ assignments_by_scope = {}
+
+ def assignments_for(scope):
+ if scope in assignments_by_scope:
+ return assignments_by_scope[scope]
+ assignments = {}
+ for node in ast.walk(scope):
+ if isinstance(node, ast.Assign):
+ for target in node.targets:
+ if isinstance(target, ast.Name):
+ assignments.setdefault(target.id, []).append(node.value)
+ elif isinstance(node, ast.AugAssign) and isinstance(
+ node.target, ast.Name
+ ):
+ assignments.setdefault(node.target.id, []).append(node.value)
+ assignments_by_scope[scope] = assignments
+ return assignments
+
+ for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
+ assignments = assignments_for(enclosing_function(call))
+ for argument in _ui_text_arguments(call):
+ values = (
+ assignments.get(argument.id, ())
+ if isinstance(argument, ast.Name)
+ else (argument,)
+ )
+ for value in values:
+ assert not _untranslated_literals(value), (
+ path,
+ call.lineno,
+ _untranslated_literals(value),
+ )
+
+
+def test_gettext_is_not_shadowed_by_dialog_return_values():
+ for path in ACTION_FILES:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ uses_gettext = any(
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ for node in ast.walk(tree)
+ )
+ if not uses_gettext:
+ continue
+ shadowing = [
+ node.lineno
+ for node in ast.walk(tree)
+ if isinstance(node, ast.Name)
+ and isinstance(node.ctx, (ast.Store, ast.Del))
+ and node.id == "_"
+ ]
+ assert not shadowing, (path, shadowing)
+
+
+def test_action_translation_placeholders_are_named_and_match():
+ formatter = string.Formatter()
+
+ def fields(value):
+ return sorted(
+ field for _, field, _, _ in formatter.parse(value) if field is not None
+ )
+
+ for source, translation in _catalog().items():
+ source_fields = fields(source)
+ assert all(field and not field.isdigit() for field in source_fields), source
+ assert source_fields == fields(translation), source
+
+ for path in ACTION_FILES:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ and node.args
+ ):
+ continue
+ source = _literal_value(node.args[0])
+ if not isinstance(source, str):
+ continue
+ expected = set(fields(source))
+ supplied = {keyword.arg for keyword in node.keywords if keyword.arg}
+ assert expected == supplied, (path, node.lineno, expected, supplied)
diff --git a/tests/actions/test_various_actions.py b/tests/actions/test_various_actions.py
index 31b03cf96..eba704b8b 100644
--- a/tests/actions/test_various_actions.py
+++ b/tests/actions/test_various_actions.py
@@ -64,9 +64,20 @@ def test_settings_wizard_open(ab_app):
actions.SettingsWizardOpen.run()
- assert application.main_window.findChild(SettingsWizard).isVisible()
+ wizard = application.main_window.findChild(SettingsWizard)
+ assert wizard.isVisible()
+ language_combo = wizard.settings_page.language_combo
+ assert [language_combo.itemData(index) for index in range(language_combo.count())] == [
+ "system",
+ "en_US",
+ "zh_CN",
+ ]
+ assert all(
+ language_combo.itemText(index) != language_combo.itemData(index)
+ for index in range(language_combo.count())
+ )
- application.main_window.findChild(SettingsWizard).destroy()
+ wizard.destroy()
def test_migrations_install(ab_app, qtbot):
@@ -80,4 +91,3 @@ def test_migrations_install(ab_app, qtbot):
actions.MigrationsInstall.run()
assert len(bi.migrations)
-
diff --git a/tests/legacy/test_settings.py b/tests/legacy/test_settings.py
index 2caf673c7..6d01fc2b7 100644
--- a/tests/legacy/test_settings.py
+++ b/tests/legacy/test_settings.py
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
+import json
import os
import pytest
+import activity_browser.settings as settings_module
from activity_browser.settings import ABSettings, BaseSettings, ProjectSettings
@@ -37,6 +39,7 @@ def test_ab_default_keys(ab_settings):
assert not {
"current_bw_dir",
"custom_bw_dirs",
+ "language",
"startup_project",
}.symmetric_difference(defaults)
@@ -52,6 +55,80 @@ def test_ab_edit_settings(ab_settings):
assert ab_settings.custom_bw_dir != ABSettings.get_default_directory()
+def test_ab_language_uses_stable_code(ab_settings):
+ assert ab_settings.language == "system"
+ ab_settings.language = "Simplified Chinese"
+ assert ab_settings.language == "zh_CN"
+
+
+def test_ab_language_migration_is_persisted(ab_settings):
+ ab_settings.settings.pop("language")
+ ab_settings.settings["ui_language"] = "简体中文"
+
+ ab_settings.migrate_settings()
+
+ assert ab_settings.settings["language"] == "zh_CN"
+ assert "ui_language" not in ab_settings.settings
+ with open(ab_settings.settings_file, "r", encoding="utf-8") as settings_file:
+ assert json.load(settings_file)["language"] == "zh_CN"
+
+
+def test_old_settings_migration_preserves_language(tmp_path):
+ settings_file = tmp_path / "legacy.json"
+ settings_file.write_text(
+ json.dumps(
+ {
+ "custom_bw_dir": str(tmp_path),
+ "language": "Simplified Chinese",
+ "startup_project": "default",
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ ABSettings.update_old_settings(str(tmp_path), settings_file.name)
+
+ migrated = json.loads(settings_file.read_text(encoding="utf-8"))
+ assert migrated["language"] == "Simplified Chinese"
+ assert migrated["current_bw_dir"] == str(tmp_path)
+ assert migrated["custom_bw_dirs"] == [str(tmp_path)]
+
+
+def test_settings_json_uses_utf8(tmp_path):
+ class UnicodeSettings(BaseSettings):
+ @classmethod
+ def get_default_settings(cls):
+ return {"label": "中文界面"}
+
+ settings = UnicodeSettings(str(tmp_path), "unicode.json")
+ settings.write_settings()
+
+ raw = (tmp_path / "unicode.json").read_bytes()
+ assert "中文界面" in raw.decode("utf-8")
+ settings.load_settings()
+ assert settings.settings["label"] == "中文界面"
+
+
+def test_remove_missing_directory_keeps_runtime_error_as_message_detail(
+ ab_settings, monkeypatch
+):
+ displayed = []
+ monkeypatch.setattr(
+ settings_module.QMessageBox,
+ "warning",
+ lambda *arguments: displayed.append(arguments),
+ )
+
+ ab_settings.remove_custom_bw_dir("directory-not-in-settings")
+
+ assert len(displayed) == 1
+ parent, title, message, button = displayed[0]
+ assert parent is None
+ assert title
+ assert "list.remove" in message
+ assert button == settings_module.QMessageBox.Ok
+
+
def test_ab_unknown_startup(ab_settings):
"""Alter the startup project with an unknown project, assert that it
was not altered because the project does not exist.
diff --git a/tests/test_backend_ui_i18n.py b/tests/test_backend_ui_i18n.py
new file mode 100644
index 000000000..deb78f548
--- /dev/null
+++ b/tests/test_backend_ui_i18n.py
@@ -0,0 +1,244 @@
+import ast
+import json
+from pathlib import Path
+from string import Formatter
+
+
+ROOT = Path(__file__).resolve().parents[1]
+PACKAGE = ROOT / "activity_browser"
+CATALOG_DIR = PACKAGE / "translations" / "zh_CN"
+SOURCE_PATHS = sorted((PACKAGE / "bwutils").rglob("*.py")) + sorted(
+ (PACKAGE / "controllers").rglob("*.py")
+) + [PACKAGE / "utils.py"]
+
+
+def source_trees():
+ return {
+ path: ast.parse(path.read_text(encoding="utf-8")) for path in SOURCE_PATHS
+ }
+
+
+def merged_catalog():
+ translations = {}
+ origins = {}
+ for path in sorted(CATALOG_DIR.glob("*.json")):
+ fragment = json.loads(path.read_text(encoding="utf-8"))
+ for source, translation in fragment.items():
+ if source in translations:
+ assert translations[source] == translation, (
+ source,
+ origins[source],
+ path,
+ )
+ translations[source] = translation
+ origins[source] = path
+ return translations
+
+
+def callee_name(call):
+ if isinstance(call.func, ast.Name):
+ return call.func.id
+ if isinstance(call.func, ast.Attribute):
+ return call.func.attr
+ return None
+
+
+def is_translation_call(node):
+ return (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ )
+
+
+def contains_translation_call(node):
+ return any(is_translation_call(candidate) for candidate in ast.walk(node))
+
+
+def literal_translation_sources(trees):
+ sources = set()
+ for tree in trees.values():
+ for node in ast.walk(tree):
+ if not (is_translation_call(node) and node.args):
+ continue
+ try:
+ source = ast.literal_eval(node.args[0])
+ except (TypeError, ValueError):
+ continue
+ if isinstance(source, str):
+ sources.add(source)
+ return sources
+
+
+def format_fields(value):
+ return {
+ field_name
+ for _, field_name, _, _ in Formatter().parse(value)
+ if field_name is not None
+ }
+
+
+def assignments_in(function):
+ assignments = {}
+ for node in ast.walk(function):
+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
+ value = node.value
+ for target in targets:
+ if isinstance(target, ast.Name):
+ assignments[target.id] = value
+ return assignments
+
+
+def translated_expression(node, assignments):
+ if contains_translation_call(node):
+ return True
+ return (
+ isinstance(node, ast.Name)
+ and node.id in assignments
+ and contains_translation_call(assignments[node.id])
+ )
+
+
+def test_backend_ui_translation_calls_are_catalogued_with_matching_fields():
+ trees = source_trees()
+ catalog = merged_catalog()
+ sources = literal_translation_sources(trees)
+
+ assert sources <= set(catalog)
+ for source in sources:
+ assert format_fields(source) == format_fields(catalog[source]), source
+
+
+def test_translation_helper_is_not_shadowed_inside_translating_functions():
+ offenders = []
+ for path, tree in source_trees().items():
+ for function in (
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+ ):
+ has_translation = any(is_translation_call(node) for node in ast.walk(function))
+ shadows_translation = any(
+ isinstance(node, ast.Name)
+ and node.id == "_"
+ and isinstance(node.ctx, ast.Store)
+ for node in ast.walk(function)
+ )
+ if has_translation and shadows_translation:
+ offenders.append((path, function.lineno, function.name))
+
+ assert offenders == []
+
+
+def test_abpopup_callers_translate_titles_messages_and_buttons():
+ popup_calls = []
+ untranslated = []
+ for path, tree in source_trees().items():
+ for function in (
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+ ):
+ assignments = assignments_in(function)
+ for node in ast.walk(function):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr in {"abCritical", "abQuestion", "abWarning"}
+ ):
+ continue
+ popup_calls.append((path, node.lineno))
+ if len(node.args) < 2:
+ untranslated.append((path, node.lineno, "missing title/message"))
+ continue
+ for role, argument in zip(("title", "message"), node.args[:2]):
+ if not translated_expression(argument, assignments):
+ untranslated.append((path, node.lineno, role))
+ for argument in node.args[2:]:
+ if (
+ isinstance(argument, ast.Call)
+ and callee_name(argument) == "QPushButton"
+ and argument.args
+ and not translated_expression(argument.args[0], assignments)
+ ):
+ untranslated.append((path, node.lineno, "button"))
+
+ assert popup_calls
+ assert untranslated == []
+
+
+def test_fixed_backend_qt_text_is_not_left_as_a_direct_literal():
+ text_constructors = {"QCheckBox", "QGroupBox", "QLabel", "QPushButton"}
+ text_methods = {
+ "setInformativeText": 0,
+ "setPlaceholderText": 0,
+ "setText": 0,
+ "setToolTip": 0,
+ "setWindowTitle": 0,
+ }
+ untranslated = []
+
+ for path, tree in source_trees().items():
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ name = callee_name(node)
+ arguments = []
+ if name in text_constructors and node.args:
+ arguments.append(node.args[0])
+ elif name in text_methods and len(node.args) > text_methods[name]:
+ arguments.append(node.args[text_methods[name]])
+ elif name == "BW2CalcError":
+ arguments.extend(node.args[:2])
+
+ if name == "getSaveFileName":
+ arguments.extend(
+ keyword.value
+ for keyword in node.keywords
+ if keyword.arg in {"caption", "filter", "selectedFilter"}
+ )
+
+ for argument in arguments:
+ if isinstance(argument, (ast.Constant, ast.JoinedStr)):
+ value = getattr(argument, "value", "formatted string")
+ if value:
+ untranslated.append((path, node.lineno, value))
+
+ assert untranslated == []
+
+
+def test_scientific_fields_and_dynamic_exception_details_stay_untranslated():
+ catalog = merged_catalog()
+ data_fields = {
+ "file",
+ "flow type",
+ "from activity name",
+ "from database",
+ "from key",
+ "to activity name",
+ "to database",
+ "to key",
+ }
+ assert data_fields.isdisjoint(catalog)
+
+ path = PACKAGE / "bwutils" / "superstructure" / "file_dialogs.py"
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ model = next(
+ node
+ for node in tree.body
+ if isinstance(node, ast.ClassDef) and node.name == "ProblemDataModel"
+ )
+ header = next(
+ node
+ for node in model.body
+ if isinstance(node, ast.FunctionDef) and node.name == "headerData"
+ )
+ assert "return str(self.columns[section])" in ast.unparse(header)
+ assert not contains_translation_call(header)
+
+ calculations = (PACKAGE / "bwutils" / "calculations.py").read_text(
+ encoding="utf-8"
+ )
+ assert "str(e)" in calculations
+ assert "_(str(e))" not in calculations
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 000000000..9ae0c2674
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,182 @@
+import json
+from pathlib import Path
+
+import pytest
+from PySide2.QtCore import QCoreApplication, QTranslator
+
+import activity_browser.i18n as i18n
+
+
+class FakeApplication:
+ def __init__(self):
+ self.translators = []
+ self.removed_translators = []
+
+ def installTranslator(self, translator):
+ self.translators.append(translator)
+ return True
+
+ def removeTranslator(self, translator):
+ self.removed_translators.append(translator)
+ if translator in self.translators:
+ self.translators.remove(translator)
+ return True
+
+
+class FakeQtTranslator:
+ def __init__(self, loadable=()):
+ self.loadable = set(loadable)
+ self.load_calls = []
+
+ def load(self, catalog_name, translations_path):
+ self.load_calls.append((catalog_name, translations_path))
+ return catalog_name in self.loadable
+
+
+def write_catalog(path, content):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(content, ensure_ascii=False), encoding="utf-8")
+
+
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ (None, "system"),
+ ("System default", "system"),
+ ("system", "system"),
+ ("en-US", "en_US"),
+ ("en-GB", "en_US"),
+ ("en_US.UTF-8", "en_US"),
+ ("en", "en_US"),
+ ("English", "en_US"),
+ ("zh-Hans", "zh_CN"),
+ ("zh-Hans-CN", "zh_CN"),
+ ("zh-TW", "zh_CN"),
+ ("zh_CN.UTF-8", "zh_CN"),
+ ("zh_CN", "zh_CN"),
+ ("中文", "zh_CN"),
+ ("Simplified Chinese", "zh_CN"),
+ ("简体中文", "zh_CN"),
+ ("unsupported", "system"),
+ ],
+)
+def test_normalize_language(value, expected):
+ assert i18n.normalize_language(value) == expected
+
+
+def test_resolve_system_language():
+ assert i18n.resolve_language("system", "zh_CN") == "zh_CN"
+ assert i18n.resolve_language("system", "zh-TW") == "zh_CN"
+ assert i18n.resolve_language("system", "fr_FR") == "en_US"
+ assert i18n.resolve_language("en_US", "zh_CN") == "en_US"
+
+
+def test_catalog_fragments_qtranslator_and_formatting(tmp_path, monkeypatch):
+ write_catalog(tmp_path / "zh_CN.json", {"Save": "保存"})
+ write_catalog(
+ tmp_path / "zh_CN" / "core.json",
+ {"Hello {name}": "你好,{name}", "Missing only in English": "已有翻译"},
+ )
+
+ manager = i18n.TranslationManager(tmp_path, system_locale="en_US")
+ application = FakeApplication()
+ assert manager.install(application, "zh_CN") == "zh_CN"
+ assert manager.requested_language == "zh_CN"
+ assert manager.current_language == "zh_CN"
+ assert manager.gettext("Save") == "保存"
+ assert manager.gettext("Hello {name}", name="王明") == "你好,王明"
+ assert manager.gettext("No catalog entry") == "No catalog entry"
+
+ catalog_translator = application.translators[-1]
+ assert isinstance(catalog_translator, QTranslator)
+ assert catalog_translator.translate("context", "Save") == "保存"
+ assert catalog_translator.translate("context", "Unknown") is None
+
+ monkeypatch.setattr(i18n, "translation_manager", manager)
+ assert i18n._("Hello {name}", name="李华") == "你好,李华"
+ assert i18n.current_language() == "zh_CN"
+
+
+def test_missing_catalog_entry_falls_back_to_qt_source_text():
+ application = QCoreApplication.instance()
+ earlier_translator = i18n.CatalogTranslator({"Unknown": "较早的翻译"})
+ translator = i18n.CatalogTranslator({"Save": "保存"})
+ application.installTranslator(earlier_translator)
+ application.installTranslator(translator)
+ try:
+ assert QCoreApplication.translate("context", "Save") == "保存"
+ assert QCoreApplication.translate("context", "Unknown") == "较早的翻译"
+ assert QCoreApplication.translate("context", "No translation") == (
+ "No translation"
+ )
+ finally:
+ application.removeTranslator(translator)
+ application.removeTranslator(earlier_translator)
+
+
+def test_conflicting_catalog_fragments_are_rejected(tmp_path):
+ write_catalog(tmp_path / "zh_CN" / "a.json", {"Save": "保存"})
+ write_catalog(tmp_path / "zh_CN" / "b.json", {"Save": "存储"})
+ manager = i18n.TranslationManager(tmp_path, system_locale="en_US")
+
+ with pytest.raises(i18n.TranslationCatalogError, match="Conflicting translation"):
+ manager.load_catalog("zh_CN")
+
+
+def test_invalid_catalog_shape_is_rejected(tmp_path):
+ write_catalog(tmp_path / "zh_CN" / "bad.json", {"Save": 42})
+ manager = i18n.TranslationManager(tmp_path, system_locale="en_US")
+
+ with pytest.raises(i18n.TranslationCatalogError, match="map strings to strings"):
+ manager.load_catalog("zh_CN")
+
+
+def test_install_replaces_translators_and_detaches_them_from_the_old_app(
+ tmp_path, monkeypatch
+):
+ write_catalog(tmp_path / "zh_CN" / "core.json", {"Save": "保存"})
+ qt_translator = FakeQtTranslator({"qtbase_zh_CN"})
+ monkeypatch.setattr(i18n, "QTranslator", lambda: qt_translator)
+
+ manager = i18n.TranslationManager(tmp_path, system_locale="en_US")
+ first_application = FakeApplication()
+ second_application = FakeApplication()
+
+ assert manager.install(first_application, "zh_CN") == "zh_CN"
+ assert [name for name, _path in qt_translator.load_calls] == [
+ "qt_zh_CN",
+ "qtbase_zh_CN",
+ ]
+ assert first_application.translators[0] is qt_translator
+ chinese_catalog_translator = first_application.translators[1]
+
+ assert manager.install(second_application, "en_US") == "en_US"
+ assert first_application.translators == []
+ assert first_application.removed_translators == [
+ chinese_catalog_translator,
+ qt_translator,
+ ]
+ assert len(second_application.translators) == 1
+ assert manager.requested_language == "en_US"
+ assert manager.current_language == "en_US"
+
+
+def test_json_catalog_translates_standard_buttons_when_qt_qm_is_unavailable(
+ monkeypatch,
+):
+ catalog_root = Path(i18n.__file__).resolve().parent / "translations"
+ qt_translator = FakeQtTranslator()
+ monkeypatch.setattr(i18n, "QTranslator", lambda: qt_translator)
+
+ manager = i18n.TranslationManager(catalog_root, system_locale="en_US")
+ application = FakeApplication()
+ manager.install(application, "zh_CN")
+
+ # No Qt translator was installed, so the context-independent JSON
+ # translator is the fallback for standard QMessageBox/QWizard text.
+ assert application.translators == [manager._catalog_translator]
+ translator = application.translators[0]
+ assert translator.translate("QPlatformTheme", "OK") == "确定"
+ assert translator.translate("QPlatformTheme", "Cancel") == "取消"
+ assert translator.translate("QDialogButtonBox", "&Cancel") == "取消(&C)"
+ assert translator.translate("QWizard", "&Finish") == "完成(&F)"
diff --git a/tests/test_i18n_catalog.py b/tests/test_i18n_catalog.py
new file mode 100644
index 000000000..799101756
--- /dev/null
+++ b/tests/test_i18n_catalog.py
@@ -0,0 +1,270 @@
+"""Dependency-free integrity checks for the Simplified Chinese catalog.
+
+These tests deliberately use only the Python standard library. They can run on
+a clean checkout even when Qt and Brightway are not installed.
+"""
+
+import ast
+from collections import Counter
+import json
+from pathlib import Path
+import re
+from string import Formatter
+
+
+ROOT = Path(__file__).resolve().parents[1]
+PACKAGE_ROOT = ROOT / "activity_browser"
+CATALOG_ROOT = PACKAGE_ROOT / "translations" / "zh_CN"
+
+QT_STANDARD_TEXT = {
+ "&Cancel",
+ "&Finish",
+ "&Next >",
+ "&No",
+ "&OK",
+ "&Save",
+ "&Yes",
+ "< &Back",
+ "Abort",
+ "Apply",
+ "Cancel",
+ "Close",
+ "Discard",
+ "Help",
+ "Ignore",
+ "No",
+ "OK",
+ "Open",
+ "Reset",
+ "Restore Defaults",
+ "Retry",
+ "Save",
+ "Save All",
+ "Yes",
+}
+
+
+# These calls intentionally translate text selected at runtime. Keeping the
+# whitelist explicit makes new dynamic calls visible in review: each runtime
+# source must itself ultimately come from a finite, translated set.
+DYNAMIC_TRANSLATION_CALLS = {
+ ("activity_browser/actions/base.py", "cls.text"),
+ ("activity_browser/actions/base.py", "tooltip"),
+ ("activity_browser/actions/parameter/parameter_new.py", "s"),
+ ("activity_browser/bwutils/superstructure/file_dialogs.py", "title"),
+ ("activity_browser/bwutils/superstructure/file_dialogs.py", "message"),
+ (
+ "activity_browser/bwutils/superstructure/file_dialogs.py",
+ "obj.button1.text()",
+ ),
+ (
+ "activity_browser/bwutils/superstructure/file_dialogs.py",
+ "obj.button2.text()",
+ ),
+ ("activity_browser/i18n.py", "_LANGUAGE_LABELS[code]"),
+ ("activity_browser/layouts/panels/left.py", "source_label"),
+ ("activity_browser/layouts/panels/right.py", "source_label"),
+ ("activity_browser/layouts/tabs/LCA_results_tabs.py", "field"),
+ ("activity_browser/layouts/tabs/parameters.py", "name"),
+ # These values are translated only within the recognized, fixed leading
+ # Score/Total + Rest rows; tests/ui/test_semantic_ids.py covers collisions.
+ ("activity_browser/ui/figures.py", "label"),
+ ("activity_browser/ui/figures.py", "raw_label"),
+ ("activity_browser/ui/figures.py", "unit"),
+ ("activity_browser/ui/tables/models/base.py", "value"),
+ ("activity_browser/ui/tables/models/base.py", "str(value)"),
+ ("activity_browser/ui/tables/delegates/uncertainty.py", "description"),
+ (
+ "activity_browser/ui/tables/models/base.py",
+ "str(self.HEADERS[column])",
+ ),
+ ("activity_browser/ui/web/base.py", "self.HELP_TEXT"),
+ ("activity_browser/ui/web/base.py", "self.PAGE_TITLE"),
+ ("activity_browser/ui/web/base.py", "line"),
+ (
+ "activity_browser/ui/web/navigator.py",
+ "'Current mode: Expansion' if self._expansion_mode else 'Current mode: Navigation'",
+ ),
+ (
+ "activity_browser/ui/wizards/db_export_wizard.py",
+ "self.FILTERS[self.selected_exporter]",
+ ),
+ ("activity_browser/ui/wizards/db_import_wizard.py", "option[0]"),
+ ("activity_browser/ui/wizards/settings_wizard.py", "theme_code"),
+ ("activity_browser/ui/wizards/uncertainty.py", "ud.description"),
+ ("activity_browser/ui/tables/views.py", "file_filter or self.ALL_FILTER"),
+ ("activity_browser/utils.py", "file_filter"),
+}
+
+
+def _catalog_pairs():
+ """Yield every source/translation pair without hiding duplicate JSON keys."""
+
+ fragments = sorted(CATALOG_ROOT.glob("*.json"))
+ assert fragments, f"No catalog fragments found in {CATALOG_ROOT}"
+
+ for path in fragments:
+ pairs = json.loads(
+ path.read_text(encoding="utf-8"), object_pairs_hook=lambda value: value
+ )
+ assert isinstance(pairs, list), f"{path} must contain one JSON object"
+ for pair in pairs:
+ assert isinstance(pair, tuple) and len(pair) == 2, (
+ f"{path} must map source strings directly to translated strings"
+ )
+ yield path, pair[0], pair[1]
+
+
+def _merged_catalog():
+ merged = {}
+ origins = {}
+ conflicts = []
+
+ for path, source, translation in _catalog_pairs():
+ if source in merged and merged[source] != translation:
+ conflicts.append(
+ f"{source!r}: {origins[source].name}={merged[source]!r}; "
+ f"{path.name}={translation!r}"
+ )
+ else:
+ merged[source] = translation
+ origins[source] = path
+
+ assert not conflicts, "Conflicting catalog entries:\n" + "\n".join(conflicts)
+ return merged
+
+
+def _format_fields(value):
+ return [
+ field_name
+ for _, field_name, _, _ in Formatter().parse(value)
+ if field_name is not None
+ ]
+
+
+def _translation_calls():
+ for path in sorted(PACKAGE_ROOT.rglob("*.py")):
+ relative_path = path.relative_to(ROOT).as_posix()
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ ):
+ continue
+ assert node.args, f"{relative_path}:{node.lineno}: _() has no source text"
+ yield relative_path, node.lineno, node.args[0]
+
+
+def test_catalog_fragments_merge_without_conflicts():
+ catalog = _merged_catalog()
+ assert catalog, "The merged Simplified Chinese catalog is empty"
+ assert catalog["Categories"] == "类别"
+
+
+def test_catalog_sources_and_translations_are_non_empty_strings():
+ invalid = []
+ for path, source, translation in _catalog_pairs():
+ if not isinstance(source, str) or not source.strip():
+ invalid.append(f"{path.name}: invalid source {source!r}")
+ if not isinstance(translation, str) or not translation.strip():
+ invalid.append(f"{path.name}: {source!r} has an empty translation")
+
+ assert not invalid, "Invalid catalog entries:\n" + "\n".join(invalid)
+
+
+def test_catalog_placeholders_are_named_and_preserved():
+ problems = []
+ for path, source, translation in _catalog_pairs():
+ try:
+ source_fields = _format_fields(source)
+ translated_fields = _format_fields(translation)
+ except ValueError as error:
+ problems.append(f"{path.name}: {source!r}: malformed braces ({error})")
+ continue
+
+ for field_name in source_fields + translated_fields:
+ root_name = re.split(r"[.\[]", field_name, maxsplit=1)[0]
+ if not root_name or root_name.isdecimal():
+ problems.append(
+ f"{path.name}: {source!r}: positional placeholder {field_name!r}"
+ )
+
+ if Counter(source_fields) != Counter(translated_fields):
+ problems.append(
+ f"{path.name}: {source!r}: source placeholders {source_fields!r}, "
+ f"translation placeholders {translated_fields!r}"
+ )
+
+ assert not problems, "Catalog placeholder errors:\n" + "\n".join(problems)
+
+
+def test_every_static_translation_literal_has_a_chinese_entry():
+ catalog = _merged_catalog()
+ missing = []
+ for path, line, expression in _translation_calls():
+ try:
+ source = ast.literal_eval(expression)
+ except (ValueError, TypeError, SyntaxError):
+ continue
+ if not isinstance(source, str):
+ missing.append(f"{path}:{line}: non-string literal {source!r}")
+ elif source not in catalog:
+ missing.append(f"{path}:{line}: {source!r}")
+
+ assert not missing, "Static _() strings missing from zh_CN:\n" + "\n".join(missing)
+
+
+def test_dynamic_translation_calls_are_explicitly_whitelisted():
+ actual = set()
+ for path, _line, expression in _translation_calls():
+ try:
+ ast.literal_eval(expression)
+ except (ValueError, TypeError, SyntaxError):
+ actual.add((path, ast.unparse(expression)))
+
+ unexpected = sorted(actual - DYNAMIC_TRANSLATION_CALLS)
+ stale = sorted(DYNAMIC_TRANSLATION_CALLS - actual)
+ assert not unexpected and not stale, (
+ "Dynamic _() call whitelist is out of date.\n"
+ f"Unexpected calls: {unexpected!r}\n"
+ f"Stale whitelist entries: {stale!r}"
+ )
+
+
+def test_catalog_files_are_utf8_and_qt_standard_text_has_json_fallbacks():
+ for path in sorted(CATALOG_ROOT.glob("*.json")):
+ path.read_bytes().decode("utf-8")
+
+ catalog = _merged_catalog()
+ assert QT_STANDARD_TEXT <= set(catalog)
+ assert all(catalog[source] != source for source in QT_STANDARD_TEXT)
+
+
+def test_bootstrap_order_and_release_resource_declarations():
+ bootstrap = (PACKAGE_ROOT / "__init__.py").read_text(encoding="utf-8")
+ assert bootstrap.index("translation_manager.install") < bootstrap.index(
+ "from .layouts.main import MainWindow"
+ )
+
+ # i18n is a leaf module in the Activity Browser import graph. Settings can
+ # import it safely while activity_browser.__init__ is still starting up.
+ i18n_tree = ast.parse((PACKAGE_ROOT / "i18n.py").read_text(encoding="utf-8"))
+ internal_imports = [
+ node
+ for node in ast.walk(i18n_tree)
+ if isinstance(node, ast.ImportFrom)
+ and node.module
+ and node.module.startswith("activity_browser")
+ ]
+ assert internal_imports == []
+
+ setup_source = (ROOT / "setup.py").read_text(encoding="utf-8")
+ manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8")
+ package_data_declaration = (
+ '"activity_browser.translations": '
+ '["*.json", "*.qm", "*/*.json", "*/*.qm"]'
+ )
+ assert package_data_declaration in setup_source
+ assert "recursive-include activity_browser *.json" in manifest
diff --git a/tests/test_progress_i18n.py b/tests/test_progress_i18n.py
new file mode 100644
index 000000000..c6f4555ba
--- /dev/null
+++ b/tests/test_progress_i18n.py
@@ -0,0 +1,96 @@
+"""Dependency-free checks for progress messages shown by worker threads."""
+
+import ast
+import json
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+PACKAGE = ROOT / "activity_browser"
+PROGRESS_SOURCES = (
+ PACKAGE / "mod" / "bw2io" / "__init__.py",
+ PACKAGE / "mod" / "bw2io" / "ecoinvent.py",
+ PACKAGE / "mod" / "bw2io" / "migrations.py",
+ PACKAGE / "mod" / "bw2io" / "importers" / "ecospold2_biosphere.py",
+ PACKAGE / "mod" / "ecoinvent_interface" / "release.py",
+)
+
+
+def qualified_name(node):
+ if isinstance(node, ast.Name):
+ return node.id
+ if isinstance(node, ast.Attribute):
+ prefix = qualified_name(node.value)
+ return f"{prefix}.{node.attr}" if prefix else node.attr
+ return ""
+
+
+def contains_call(node, name):
+ return any(
+ isinstance(part, ast.Call)
+ and qualified_name(part.func).rsplit(".", 1)[-1] == name
+ for part in ast.walk(node)
+ )
+
+
+def test_worker_progress_titles_and_info_messages_are_translated():
+ progress_titles = []
+ info_messages = []
+
+ for path in PROGRESS_SOURCES:
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ callee = qualified_name(node.func).rsplit(".", 1)[-1]
+ if callee in {"ProgBar", "prog_bar"}:
+ progress_titles.extend(
+ keyword.value for keyword in node.keywords if keyword.arg == "title"
+ )
+ elif callee == "info" and node.args:
+ info_messages.append(node.args[0])
+
+ assert progress_titles
+ assert info_messages
+ assert all(contains_call(node, "_") for node in progress_titles)
+ assert all(contains_call(node, "_") for node in info_messages)
+
+
+def test_migration_progress_labels_use_one_translated_template():
+ path = PACKAGE / "mod" / "bw2io" / "migrations.py"
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ title_assignments = [
+ node.value
+ for node in ast.walk(tree)
+ if isinstance(node, ast.Assign)
+ and any(
+ isinstance(target, ast.Attribute) and target.attr == "title"
+ for target in node.targets
+ )
+ ]
+
+ assert len(title_assignments) == 12
+ assert all(contains_call(node, "_migration_title") for node in title_assignments)
+
+
+def test_progress_catalog_preserves_dynamic_identifiers():
+ catalog = json.loads(
+ (PACKAGE / "translations" / "zh_CN" / "progress.json").read_text(
+ encoding="utf-8"
+ )
+ )
+
+ assert catalog["Creating migration: {migration}"].count("{migration}") == 1
+ assert catalog["Installing biosphere version {version}"].count("{version}") == 1
+ assert catalog["Applying biosphere patch: {patch}"].count("{patch}") == 1
+ close_match = catalog[
+ "Using close match {match} for predicted filename {filename}"
+ ]
+ assert close_match.count("{match}") == 1
+ assert close_match.count("{filename}") == 1
+
+
+def test_logging_progress_handler_formats_records_itself():
+ source = (PACKAGE / "ui" / "threading.py").read_text(encoding="utf-8")
+ assert "record.getMessage()" in source
+ assert "record.message" not in source
diff --git a/tests/ui/test_lca_results_localization.py b/tests/ui/test_lca_results_localization.py
new file mode 100644
index 000000000..955220c66
--- /dev/null
+++ b/tests/ui/test_lca_results_localization.py
@@ -0,0 +1,295 @@
+import ast
+import json
+import re
+import string
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+RESULT_FILES = (
+ ROOT / "activity_browser" / "layouts" / "tabs" / "LCA_results_tabs.py",
+ ROOT / "activity_browser" / "layouts" / "tabs" / "LCA_results_tab.py",
+)
+CATALOG_DIR = ROOT / "activity_browser" / "translations" / "zh_CN"
+RESULTS_CATALOG = CATALOG_DIR / "results.json"
+
+
+def literal_string(node):
+ try:
+ value = ast.literal_eval(node)
+ except (TypeError, ValueError):
+ return None
+ return value if isinstance(value, str) else None
+
+
+def is_translation_call(node):
+ return (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ )
+
+
+def static_translation_sources(tree):
+ sources = set()
+ for node in ast.walk(tree):
+ if is_translation_call(node) and node.args:
+ source = literal_string(node.args[0])
+ if source is not None:
+ sources.add(source)
+ return sources
+
+
+def test_results_catalog_covers_every_static_translation_and_aggregation_label():
+ catalog = json.loads(RESULTS_CATALOG.read_text(encoding="utf-8"))
+ sources = set()
+ for path in RESULT_FILES:
+ sources.update(static_translation_sources(ast.parse(path.read_text())))
+
+ # Aggregation labels are translated from stable dataframe field IDs at
+ # runtime, so their source values cannot be discovered as `_` literals.
+ sources.update(
+ {
+ "none",
+ "reference product",
+ "name",
+ "location",
+ "unit",
+ "database",
+ "categories",
+ "type",
+ }
+ )
+ assert not sources.difference(catalog)
+
+
+def test_results_catalog_has_no_conflicts_and_preserves_placeholders_and_links():
+ merged = {}
+ formatter = string.Formatter()
+ for path in sorted(CATALOG_DIR.glob("*.json")):
+ fragment = json.loads(path.read_text(encoding="utf-8"))
+ for source, translation in fragment.items():
+ assert source not in merged or merged[source] == translation
+ merged[source] = translation
+
+ catalog = json.loads(RESULTS_CATALOG.read_text(encoding="utf-8"))
+ for source, translation in catalog.items():
+ source_fields = {name for _, name, _, _ in formatter.parse(source) if name}
+ translated_fields = {
+ name for _, name, _, _ in formatter.parse(translation) if name
+ }
+ assert source_fields == translated_fields
+ if source.startswith(""):
+ assert re.findall(r'href="([^"]+)"', source) == re.findall(
+ r'href="([^"]+)"', translation
+ )
+
+ assert catalog["{name}[Scenarios]"].format(name="ecoinvent 数据") == (
+ "ecoinvent 数据[情景]"
+ )
+
+
+def test_fixed_widget_text_is_always_translated():
+ constructors = {
+ "QLabel",
+ "QCheckBox",
+ "QRadioButton",
+ "QPushButton",
+ "QGroupBox",
+ "header",
+ "get_header_layout",
+ "get_header_layout_w_help",
+ }
+ format_only_labels = {".png", ".svg", ".csv"}
+
+ for path in RESULT_FILES:
+ tree = ast.parse(path.read_text())
+ assert not any(
+ isinstance(node, ast.Name)
+ and node.id == "_"
+ and isinstance(node.ctx, (ast.Store, ast.Del))
+ for node in ast.walk(tree)
+ ), f"The translation function is shadowed in {path}"
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
+ if node.func.id in constructors and node.args:
+ source = literal_string(node.args[0])
+ if source and source not in format_only_labels:
+ raise AssertionError(
+ f"Untranslated {node.func.id} text at {path}:{node.lineno}: "
+ f"{source!r}"
+ )
+ elif node.func.id == "QMessageBox" and len(node.args) >= 2:
+ assert literal_string(node.args[1]) is None, (
+ path,
+ node.lineno,
+ literal_string(node.args[1]),
+ )
+
+ if not isinstance(node, ast.Call) or not isinstance(
+ node.func, ast.Attribute
+ ):
+ continue
+
+ method = node.func.attr
+ if method == "setToolTip" and node.args:
+ assert literal_string(node.args[0]) is None, (
+ path,
+ node.lineno,
+ literal_string(node.args[0]),
+ )
+ elif method == "addAction" and len(node.args) >= 2:
+ assert literal_string(node.args[1]) is None, (
+ path,
+ node.lineno,
+ literal_string(node.args[1]),
+ )
+ elif method == "getSaveFileName":
+ for keyword in node.keywords:
+ if keyword.arg in {"caption", "filter"}:
+ assert literal_string(keyword.value) is None, (
+ path,
+ node.lineno,
+ keyword.arg,
+ )
+ elif method in {"warning", "information", "critical"}:
+ if len(node.args) >= 2:
+ assert literal_string(node.args[1]) is None, (
+ path,
+ node.lineno,
+ literal_string(node.args[1]),
+ )
+
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
+ continue
+ target = node.targets[0]
+ if isinstance(target, ast.Attribute) and target.attr in {
+ "explain_text",
+ "tab_text",
+ }:
+ assert is_translation_call(node.value), (path, node.lineno, target.attr)
+
+ get_unit = next(
+ (
+ node
+ for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name == "get_unit"
+ ),
+ None,
+ )
+ if get_unit is not None:
+ returned_literals = {
+ literal_string(node.value)
+ for node in ast.walk(get_unit)
+ if isinstance(node, ast.Return)
+ }
+ assert {
+ "relative share",
+ "units of each impact category",
+ }.issubset(returned_literals)
+ assert not any(
+ is_translation_call(node) for node in ast.walk(get_unit)
+ )
+
+
+def test_scientific_data_and_internal_result_ids_are_not_translated():
+ tabs_source = RESULT_FILES[0].read_text()
+ container_source = RESULT_FILES[1].read_text()
+
+ # Dynamic scientific values continue to be read verbatim from their data
+ # comboboxes and dictionaries.
+ assert "self.parent.method_dict[self.combobox_menu.method.currentText()]" in (
+ tabs_source
+ )
+ assert "functional_unit = self.combobox_menu.func.currentText()" in tabs_source
+ assert "return bc.unit_of_method(method)" in tabs_source
+ assert 'data = {"Score": score}' in tabs_source
+ assert 'data["Range"] = sum(_range)' in tabs_source
+ assert 'score_and_rest[col].extend(["Score", "Rest (+)", "Rest (-)"])' in (
+ tabs_source
+ )
+ assert '[_(' not in tabs_source.split("score_and_rest[col].extend", 1)[1].split(
+ ")", 1
+ )[0]
+ assert '["reference product", "name", "location", "unit"]' in tabs_source
+
+ # Raw exception text is retained; only fixed titles and guidance translate.
+ assert '_("Could not perform Monte Carlo simulation"), str(e)' in tabs_source
+ assert '_("Could not perform GSA"), str(message) + message_addition' in tabs_source
+ assert '_("Calculation problem"),\n str(initial)' in container_source
+
+ # Internal calculation type and dictionary keys retain their stable values;
+ # only the separate tab label gets the localized scenario suffix.
+ assert 'calculation_type == "scenario"' in container_source
+ assert 'internal_name = "{}[Scenarios]".format(cs_name)' in container_source
+ assert "self.tabs[internal_name] = new_tab" in container_source
+ assert "self.addTab(new_tab, display_name)" in container_source
+ assert 'signals.show_tab.emit("LCA results")' in container_source
+
+
+def test_result_values_translate_only_on_display_copies_not_in_exports():
+ model_source = (
+ ROOT
+ / "activity_browser"
+ / "ui"
+ / "tables"
+ / "models"
+ / "lca_results.py"
+ ).read_text(encoding="utf-8")
+ base_source = (
+ ROOT / "activity_browser" / "ui" / "tables" / "models" / "base.py"
+ ).read_text(encoding="utf-8")
+ figure_source = (
+ ROOT / "activity_browser" / "ui" / "figures.py"
+ ).read_text(encoding="utf-8")
+
+ for value in (
+ '"Score"',
+ '"Rest (+)"',
+ '"Rest (-)"',
+ '"relative share"',
+ '"units of each impact category"',
+ ):
+ assert value in model_source
+
+ # The model translates selected fixed values only after reading them for a
+ # display/tooltip role. Export methods continue to use `_dataframe` raw.
+ assert "value in self.TRANSLATABLE_VALUES" in base_source
+ assert "value = _(value)" in base_source
+ for method_call in (
+ "self._dataframe.iloc[rows, columns].to_clipboard",
+ "self._dataframe.to_csv(path)",
+ "self._dataframe.to_excel(excel_writer=path)",
+ ):
+ assert method_call in base_source
+
+ # Plot labels are prepared on separate dataframes and only for the
+ # recognized built-in prefix, not for a later data row with the same text.
+ assert "def prepare_contribution_plot_dataframe" in figure_source
+ assert "def prepare_lca_results_plot_dataframe" in figure_source
+ assert "source.select_dtypes(include=np.number).copy()" in figure_source
+ assert "fixed_prefix" in figure_source
+ assert "is_fixed = position < fixed_display_rows" in figure_source
+ assert "if translate_unit else unit" in figure_source
+
+
+def test_calculation_tab_label_localizes_only_the_scenario_suffix():
+ source = RESULT_FILES[1].read_text()
+ tree = ast.parse(source)
+ helper = next(
+ node
+ for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name == "calculation_tab_label"
+ )
+ module = ast.fix_missing_locations(ast.Module(body=[helper], type_ignores=[]))
+ catalog = json.loads(RESULTS_CATALOG.read_text(encoding="utf-8"))
+ namespace = {
+ "_": lambda value, **kwargs: catalog.get(value, value).format(**kwargs)
+ }
+ exec(compile(module, str(RESULT_FILES[1]), "exec"), namespace)
+
+ label = namespace["calculation_tab_label"]
+ scientific_name = "market for electricity | CN | 千瓦时"
+ assert label(scientific_name, "simple") == scientific_name
+ assert label(scientific_name, "scenario") == scientific_name + "[情景]"
diff --git a/tests/ui/test_semantic_ids.py b/tests/ui/test_semantic_ids.py
new file mode 100644
index 000000000..693f4378b
--- /dev/null
+++ b/tests/ui/test_semantic_ids.py
@@ -0,0 +1,686 @@
+from types import SimpleNamespace
+
+import pandas as pd
+from PySide2.QtCore import QModelIndex, Qt
+from PySide2 import QtWidgets
+from PySide2.QtGui import QStandardItemModel
+
+from activity_browser.layouts.panels.panel import ABTab, TabId
+from activity_browser.layouts.tabs.LCA_results_tabs import (
+ CategorisationFilter,
+ ContributionTab,
+ InventoryTab,
+ InventoryType,
+)
+from activity_browser.ui.tables.models.base import (
+ BaseTreeModel,
+ FilterMode,
+ FilterOperator,
+ PandasModel,
+ TreeItem,
+)
+from activity_browser.ui.tables.models.lca_results import (
+ ContributionModel,
+ InventoryModel,
+ LCAResultsModel,
+)
+from activity_browser.ui.tables.models.lca_setup import CSActivityModel, CSMethodsModel
+from activity_browser.ui.tables.delegates.uncertainty import UncertaintyDelegate
+from activity_browser.ui.tables.views import ABFilterableDataFrameView
+from activity_browser.ui.tables.impact_categories import (
+ MethodCharacterizationFactorsTable,
+ MethodsTable,
+)
+from activity_browser.ui.figures import (
+ CHINESE_PLOT_FONTS,
+ configure_plot_fonts,
+ prepare_contribution_plot_dataframe,
+ prepare_lca_results_plot_dataframe,
+)
+from activity_browser.ui.widgets.comparison_switch import ComparisonMode, SwitchComboBox
+from activity_browser.ui.widgets.dialog import (
+ AndOrRadioButtons,
+ FilterManagerDialog,
+ NumFilterRow,
+ SimpleFilterDialog,
+ StrFilterRow,
+)
+
+
+def test_panel_tab_logic_uses_stable_id_and_accepts_legacy_alias(qtbot):
+ panel = ABTab()
+ tab = QtWidgets.QWidget()
+ qtbot.addWidget(panel)
+
+ panel.add_tab(
+ tab,
+ TabId.GRAPH_EXPLORER,
+ "图形浏览器",
+ aliases=("Graph Explorer",),
+ )
+
+ assert panel.tabText(0) == "图形浏览器"
+ assert panel.get_tab_name_from_index(0) == TabId.GRAPH_EXPLORER
+
+ panel.hide_tab(TabId.GRAPH_EXPLORER)
+ assert panel.indexOf(tab) == -1
+
+ # Older emitters can still use the historical English label, but the
+ # translated display label is never used as the internal key.
+ panel.show_tab("Graph Explorer")
+ assert panel.indexOf(tab) == 0
+ assert panel.tabText(0) == "图形浏览器"
+
+
+def test_filter_rows_return_item_data_after_labels_are_translated(qtbot):
+ filter_types = ABFilterableDataFrameView.FILTER_TYPES
+
+ string_row = StrFilterRow(idx=0, filter_types=filter_types, remove_option=False)
+ qtbot.addWidget(string_row)
+ string_row.filter_type_box.setItemText(0, "包含")
+ string_row.set_state((FilterOperator.CONTAINS, "coal", False))
+
+ assert string_row.filter_type_box.currentText() == "包含"
+ assert string_row.get_state == (FilterOperator.CONTAINS, "coal", False)
+
+ numeric_row = NumFilterRow(idx=0, filter_types=filter_types, remove_option=False)
+ qtbot.addWidget(numeric_row)
+ between_index = numeric_row.filter_ids.index(FilterOperator.BETWEEN)
+ numeric_row.filter_type_box.setItemText(between_index, "介于")
+ numeric_row.set_state((FilterOperator.BETWEEN, ("1", "2")))
+
+ assert numeric_row.filter_type_box.currentText() == "介于"
+ assert numeric_row.filter_query_line0.isHidden() is False
+ assert numeric_row.get_state == (FilterOperator.BETWEEN, ("1", "2"))
+
+
+def test_and_or_mode_does_not_depend_on_radio_button_text(qtbot):
+ buttons = AndOrRadioButtons()
+ qtbot.addWidget(buttons)
+
+ buttons.AND.setText("并且")
+ buttons.OR.setText("或者")
+ buttons.set_state(FilterMode.OR)
+
+ assert buttons.get_state == FilterMode.OR
+
+
+def test_filter_model_uses_stable_operator_and_mode_ids():
+ model = PandasModel(
+ pd.DataFrame(
+ {
+ "name": ["hard coal", "wind power", "coal market"],
+ "amount": [1.0, 2.0, 3.0],
+ }
+ )
+ )
+ model.filterable_columns = {"name": 0, "amount": 1}
+ model.different_column_types = {"amount": "num"}
+ filters = {
+ 0: {"filters": [(FilterOperator.CONTAINS, "coal", False)]},
+ 1: {
+ "filters": [(FilterOperator.GREATER_THAN_OR_EQUAL, "2")],
+ },
+ "mode": FilterMode.AND,
+ }
+
+ assert model.get_filter_mask(filters).tolist() == [False, False, True]
+
+
+def test_comparison_mode_does_not_depend_on_combobox_text(qtbot):
+ parent = QtWidgets.QWidget()
+ parent.has_scenarios = True
+ qtbot.addWidget(parent)
+ box = SwitchComboBox(parent)
+ box.configure()
+
+ assert [box.itemData(i) for i in range(box.count())] == [
+ ComparisonMode.FUNCTIONAL_UNITS,
+ ComparisonMode.IMPACT_CATEGORIES,
+ ComparisonMode.SCENARIOS,
+ ]
+
+ box.setItemText(box.indexes.method, "影响类别")
+ box.setCurrentIndex(box.indexes.method)
+ assert box.currentText() == "影响类别"
+ assert box.current_mode == ComparisonMode.IMPACT_CATEGORIES
+
+
+def test_lca_aggregation_labels_preserve_dataframe_fields(qtbot, monkeypatch):
+ translations = {"none": "不聚合", "location": "地点"}
+ monkeypatch.setattr(
+ "activity_browser.layouts.tabs.LCA_results_tabs._",
+ lambda source: translations.get(source, source),
+ )
+ box = QtWidgets.QComboBox()
+ qtbot.addWidget(box)
+
+ ContributionTab.add_aggregation_items(box, ["none", "location"])
+
+ assert [box.itemText(i) for i in range(box.count())] == ["不聚合", "地点"]
+ assert [box.itemData(i) for i in range(box.count())] == ["none", "location"]
+
+
+def test_inventory_filters_use_combobox_data_and_button_property():
+ class Combo:
+ def __init__(self, value):
+ self.value = value
+
+ def currentData(self):
+ return self.value
+
+ holder = SimpleNamespace(
+ bio_categorisation_factor_group=Combo(CategorisationFilter.WITH_FACTORS),
+ categorisation_filter_with_flows=None,
+ categorisation_factor_state=None,
+ old_categorisation_factor_state=None,
+ update_table=lambda: None,
+ )
+ InventoryTab.add_categorisation_factor_filter(holder, 0)
+ assert holder.categorisation_filter_with_flows is True
+
+ visibility = []
+ holder.categorisation_filter_box = SimpleNamespace(
+ setVisible=lambda visible: visibility.append(visible)
+ )
+ translated_button = SimpleNamespace(
+ property=lambda name: InventoryType.BIOSPHERE,
+ text=lambda: "生物圈流",
+ )
+ InventoryTab.toggle_categorisation_factor_filter_buttons(
+ holder, translated_button
+ )
+ assert visibility == [True]
+
+
+def test_contribution_labels_translate_only_in_builtin_rows(monkeypatch):
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.base._",
+ lambda value: {"Score": "得分", "Rest (+)": "其余(正)"}.get(value, value),
+ )
+ model = ContributionModel()
+ source = pd.DataFrame(
+ {
+ "index": ["Score", "Rest (+)", "Rest (-)", "Score"],
+ "unit": ["kg", "kg", "kg", "kg"],
+ "result": [4.0, 1.0, -1.0, 2.0],
+ }
+ )
+
+ model.sync(source, unit="kg")
+ label_column = model._dataframe.columns.get_loc("index")
+ builtin = model.index(0, label_column)
+ user_data = model.index(3, label_column)
+
+ assert model.data(builtin, Qt.DisplayRole) == "得分"
+ assert model.data(user_data, Qt.DisplayRole) == "Score"
+ assert model.data(builtin, "sorting") == "Score"
+ assert model._dataframe.iloc[3, label_column] == "Score"
+
+
+def test_result_headers_translate_only_program_defined_metadata(monkeypatch):
+ translations = {
+ "name": "名称",
+ "unit": "单位",
+ "database": "数据库",
+ "code": "代码",
+ }
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.base._",
+ lambda value: translations.get(value, value),
+ )
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.lca_results._",
+ lambda value: translations.get(value, value),
+ )
+ columns = [
+ "index",
+ "amount",
+ "unit",
+ "reference product",
+ "name",
+ "location",
+ "database",
+ "name",
+ ]
+ model = LCAResultsModel()
+ model.sync(
+ pd.DataFrame(
+ [["row", 1.0, "kg", "product", "activity", "CN", "db", 4.2]],
+ columns=columns,
+ )
+ )
+
+ assert model.headerData(4, Qt.Horizontal, Qt.DisplayRole) == "名称"
+ assert model.headerData(7, Qt.Horizontal, Qt.DisplayRole) == "name"
+ assert list(model._dataframe.columns) == columns
+
+ monte_carlo = LCAResultsModel()
+ monte_carlo.sync(pd.DataFrame({"name": [1.0]}))
+ assert monte_carlo.headerData(0, Qt.Horizontal, Qt.DisplayRole) == "name"
+
+ scientific_schema_collision = LCAResultsModel()
+ scientific_schema_collision.sync(
+ pd.DataFrame(
+ [[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]],
+ columns=[
+ "amount",
+ "unit",
+ "reference product",
+ "name",
+ "location",
+ "database",
+ ],
+ )
+ )
+ assert (
+ scientific_schema_collision.headerData(3, Qt.Horizontal, Qt.DisplayRole)
+ == "name"
+ )
+
+ index_alias_collision = LCAResultsModel()
+ index_alias_collision.sync(
+ pd.DataFrame(
+ [[1.0, 2.0, 3.0]],
+ columns=["level_0", "level_1", "amount"],
+ )
+ )
+ assert (
+ index_alias_collision.headerData(0, Qt.Horizontal, Qt.DisplayRole)
+ == "level_0"
+ )
+
+ # Pandas names unnamed MultiIndex fields ``level_0`` and ``level_1``.
+ # Only their display headers are made meaningful; exported/raw fields and
+ # dynamically named result columns remain unchanged.
+ overview_columns = [
+ "level_0",
+ "level_1",
+ "amount",
+ "unit",
+ "reference product",
+ "name",
+ "location",
+ "database",
+ ("method", "category"),
+ ]
+ overview = LCAResultsModel()
+ overview.sync(
+ pd.DataFrame(
+ [["db", "code", 1.0, "kg", "product", "activity", "CN", "db", 2.0]],
+ columns=overview_columns,
+ )
+ )
+ assert overview.headerData(0, Qt.Horizontal, Qt.DisplayRole) == "数据库"
+ assert overview.headerData(1, Qt.Horizontal, Qt.DisplayRole) == "代码"
+ assert overview.headerData(8, Qt.Horizontal, Qt.DisplayRole) == (
+ "method",
+ "category",
+ )
+ assert list(overview._dataframe.columns) == overview_columns
+
+
+def test_filter_dialogs_use_display_labels_but_keep_raw_column_keys(
+ qtbot, monkeypatch
+):
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.base._",
+ lambda value: {"name": "名称", "unit": "单位"}.get(value, value),
+ )
+ model = InventoryModel()
+ model.sync(
+ pd.DataFrame(
+ {
+ "name": ["flow"],
+ "categories": ["air"],
+ "type": ["biosphere"],
+ "unit": ["kg"],
+ "database": ["db"],
+ "scientific result": [1.0],
+ }
+ )
+ )
+
+ manager = FilterManagerDialog(
+ column_names=model.filterable_columns,
+ column_labels={
+ column: model.headerData(column, Qt.Horizontal, Qt.DisplayRole)
+ for column in model.filterable_columns.values()
+ },
+ filter_types=ABFilterableDataFrameView.FILTER_TYPES,
+ )
+ qtbot.addWidget(manager)
+ assert manager.tab_widget.tabText(0) == "名称"
+ assert manager.tab_widget.tabText(5) == "scientific result"
+
+ simple = SimpleFilterDialog(
+ column_name="name",
+ column_label=model.headerData(0, Qt.Horizontal, Qt.DisplayRole),
+ filter_types=ABFilterableDataFrameView.FILTER_TYPES,
+ )
+ qtbot.addWidget(simple)
+ visible_text = [label.text() for label in simple.findChildren(QtWidgets.QLabel)]
+ assert any("名称" in text for text in visible_text)
+
+ # Filtering still addresses the raw dataframe field, never the display label.
+ assert model.filterable_columns["name"] == 0
+ assert "名称" not in model.filterable_columns
+
+
+def test_filter_manager_maps_non_contiguous_raw_columns_to_display_tabs(qtbot):
+ manager = FilterManagerDialog(
+ column_names={"Name": 0, "Amount": 3},
+ column_labels={0: "名称", 3: "数值"},
+ column_types={"Amount": "num"},
+ filter_types=ABFilterableDataFrameView.FILTER_TYPES,
+ selected_column=3,
+ )
+ qtbot.addWidget(manager)
+
+ assert manager.tab_widget.currentIndex() == 1
+ assert [manager.tab_widget.tabText(i) for i in range(2)] == ["名称", "数值"]
+
+ amount_filter = manager.tabs[1].filter_rows[0]
+ amount_filter.filter_query_line.setText("2")
+ state = manager.get_filters
+ assert state[3]["filters"] == [(FilterOperator.NUM_EQUALS, "2")]
+ assert state["mode"] == FilterMode.AND
+
+
+def test_cf_amount_edit_uses_raw_column_identity(monkeypatch):
+ calls = []
+
+ class Cell:
+ def column(self):
+ return 3
+
+ class Model:
+ HEADERS = ["Name", "Category", "Database", "Amount"]
+
+ def headerData(self, *_args):
+ raise AssertionError("Display text must not be used as a column key")
+
+ def get_value(self, _cell):
+ return 2.5
+
+ holder = SimpleNamespace(
+ model=Model(),
+ selectedIndexes=lambda: [Cell()],
+ method_name=lambda: ("method",),
+ selected_cfs=lambda: [("db", "flow")],
+ )
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.impact_categories.actions.CFAmountModify.run",
+ staticmethod(
+ lambda method, cfs, amount: calls.append((method, cfs, amount))
+ ),
+ )
+
+ MethodCharacterizationFactorsTable.cell_edited(holder)
+ assert calls == [(("method",), [("db", "flow")], 2.5)]
+
+
+def test_contribution_pseudo_unit_requires_explicit_display_marker(monkeypatch):
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.base._",
+ lambda value: "相对占比" if value == "relative share" else value,
+ )
+ source = pd.DataFrame(
+ {
+ "index": ["Score", "Rest (+)", "Rest (-)", "dataset"],
+ "unit": ["", "", "", "relative share"],
+ "result": [4.0, 1.0, -1.0, 2.0],
+ }
+ )
+
+ scientific_unit = ContributionModel()
+ scientific_unit.sync(
+ source.copy(), unit="relative share", translate_unit=False
+ )
+ unit_column = scientific_unit._dataframe.columns.get_loc("unit")
+ assert (
+ scientific_unit.data(scientific_unit.index(3, unit_column), Qt.DisplayRole)
+ == "relative share"
+ )
+
+ display_unit = ContributionModel()
+ display_unit.sync(source.copy(), unit="relative share", translate_unit=True)
+ assert (
+ display_unit.data(display_unit.index(3, unit_column), Qt.DisplayRole)
+ == "相对占比"
+ )
+
+
+def test_plot_copies_translate_only_builtin_result_rows(monkeypatch):
+ translations = {
+ "Score": "得分",
+ "Rest (+)": "其余(正)",
+ "Rest (-)": "其余(负)",
+ }
+ monkeypatch.setattr(
+ "activity_browser.ui.figures._",
+ lambda value: translations.get(value, value),
+ )
+ source = pd.DataFrame(
+ {
+ "index": ["Score", "Rest (+)", "Rest (-)", "Score", "Rest (+)"],
+ "unit": ["", "", "", "kg", "kg"],
+ "result": [5.0, 1.0, -1.0, 2.0, 3.0],
+ }
+ )
+ original = source.copy(deep=True)
+
+ contribution, grey_rows = prepare_contribution_plot_dataframe(source)
+ assert list(contribution.index) == [
+ "其余(正)",
+ "其余(负)",
+ "Score",
+ "Rest (+)",
+ ]
+ assert grey_rows == (0, 1)
+
+ heatmap_source = source.assign(
+ amount=[1.0] * len(source),
+ name=["dataset"] * len(source),
+ database=["database"] * len(source),
+ )
+ heatmap = prepare_lca_results_plot_dataframe(heatmap_source)
+ assert list(heatmap.index) == [
+ "其余(正)",
+ "其余(负)",
+ "Score",
+ "Rest (+)",
+ ]
+ assert "amount" not in heatmap.columns
+ pd.testing.assert_frame_equal(source, original)
+
+ # Without the recognized prefix these are scientific labels, and a numeric
+ # result column named ``amount`` is not mistaken for overview metadata.
+ user_only = pd.DataFrame(
+ {"index": ["Score", "Rest (+)"], "amount": [2.0, 3.0]}
+ )
+ user_heatmap = prepare_lca_results_plot_dataframe(user_only)
+ assert list(user_heatmap.index) == ["Score", "Rest (+)"]
+ assert list(user_heatmap.columns) == ["amount"]
+
+
+def test_tree_display_translation_keeps_raw_user_role(monkeypatch):
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.base._",
+ lambda value: "未分类" if value == "No classification" else value,
+ )
+ model = BaseTreeModel()
+ model.HEADERS = ["name"]
+ model.TRANSLATABLE_VALUES = ("No classification",)
+ model.root = TreeItem.build_root(model.HEADERS)
+ child = TreeItem(["No classification"], model.root)
+ model.root.appendChild(child)
+ index = model.index(0, 0, QModelIndex())
+
+ assert model.data(index, Qt.DisplayRole) == "未分类"
+ assert model.data(index, Qt.UserRole) == "No classification"
+
+
+def test_scenario_filename_placeholder_is_visible_plain_text(qtbot, monkeypatch):
+ from activity_browser.layouts.tabs import LCA_setup
+
+ monkeypatch.setattr(
+ LCA_setup,
+ "_",
+ lambda value: "〈文件名〉" if value == "〈filename〉" else value,
+ )
+ widget = LCA_setup.ScenarioImportWidget(0)
+ qtbot.addWidget(widget)
+
+ assert widget.scenario_name.text() == "〈文件名〉"
+
+
+def test_methods_list_keeps_internal_tuple_column_hidden(qtbot):
+ table = MethodsTable()
+ qtbot.addWidget(table)
+
+ assert table.isColumnHidden(table.model.method_col)
+ assert "method" not in table.model.filterable_columns
+
+ table.sync()
+ assert table.isColumnHidden(table.model.method_col)
+
+
+def test_missing_calculation_setup_objects_localize_display_only(monkeypatch):
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.models.lca_setup._",
+ lambda source, **values: (
+ "未找到:{value}".format(**values)
+ if source == "NOT FOUND: {value}"
+ else source.format(**values)
+ ),
+ )
+
+ activity_key = ("missing-db", "missing-code")
+ activity = CSActivityModel()
+ activity._dataframe = pd.DataFrame(
+ [
+ {
+ "Amount": 1.0,
+ "Unit": "",
+ "Product": "",
+ "Activity": f"NOT FOUND: {activity_key}",
+ "Location": "",
+ "Database": "missing-db",
+ "key": activity_key,
+ }
+ ],
+ columns=activity.HEADERS,
+ )
+ activity.key_col = activity._dataframe.columns.get_loc("key")
+ activity_column = activity._dataframe.columns.get_loc("Activity")
+ activity_index = activity.index(0, activity_column)
+
+ assert activity.data(activity_index, Qt.DisplayRole).startswith("未找到:")
+ assert activity.data(activity_index, "sorting").startswith("NOT FOUND:")
+ assert activity._dataframe.iat[0, activity_column].startswith("NOT FOUND:")
+
+ method_key = ("missing method",)
+ method = CSMethodsModel()
+ method._dataframe = pd.DataFrame(
+ [
+ {
+ "Name": f"NOT FOUND: {method_key}",
+ "Unit": "Unknown",
+ "# CFs": 0,
+ "method": method_key,
+ }
+ ],
+ columns=method.HEADERS,
+ )
+ method_index = method.index(0, method._dataframe.columns.get_loc("Name"))
+
+ assert method.data(method_index, Qt.DisplayRole).startswith("未找到:")
+ assert method.data(method_index, "sorting").startswith("NOT FOUND:")
+
+ # A valid user object whose name starts with the same words is data, not UI.
+ method._methods[method_key] = object()
+ assert method.data(method_index, Qt.DisplayRole).startswith("NOT FOUND:")
+
+
+def test_parameter_scenario_row_mismatch_uses_fixed_translatable_message(
+ monkeypatch
+):
+ from activity_browser.layouts.tabs import parameters as parameter_tabs
+ from activity_browser.ui.tables.models.scenarios import TooManyParametersError
+
+ def reject_scenario(**_kwargs):
+ raise TooManyParametersError
+
+ messages = []
+ holder = SimpleNamespace(
+ tbl=SimpleNamespace(model=SimpleNamespace(sync=reject_scenario)),
+ build_flow_scenarios=lambda: None,
+ )
+ translations = {
+ "Cannot load parameters": "无法加载参数",
+ "The scenario file contains more parameter rows than the current project.": (
+ "情景文件中的参数行数多于当前项目,无法加载。"
+ ),
+ }
+ monkeypatch.setattr(
+ parameter_tabs, "_", lambda source: translations.get(source, source)
+ )
+ monkeypatch.setattr(
+ parameter_tabs.QMessageBox,
+ "critical",
+ lambda _parent, title, detail, *_buttons: messages.append((title, detail)),
+ )
+
+ parameter_tabs.ParameterScenariosTab.process_scenarios(
+ holder, 0, pd.DataFrame(), False
+ )
+
+ assert messages == [
+ ("无法加载参数", "情景文件中的参数行数多于当前项目,无法加载。")
+ ]
+
+
+def test_chinese_plot_font_fallbacks_are_configured():
+ import matplotlib.pyplot as plt
+
+ original = list(plt.rcParams["font.sans-serif"])
+ original_unicode_minus = plt.rcParams["axes.unicode_minus"]
+ try:
+ configure_plot_fonts("zh_CN")
+ configured = list(plt.rcParams["font.sans-serif"])
+ assert configured[: len(CHINESE_PLOT_FONTS)] == list(CHINESE_PLOT_FONTS)
+ assert "DejaVu Sans" in configured
+ assert plt.rcParams["axes.unicode_minus"] is False
+ finally:
+ plt.rcParams["font.sans-serif"] = original
+ plt.rcParams["axes.unicode_minus"] = original_unicode_minus
+
+
+def test_uncertainty_delegate_translates_display_only(monkeypatch):
+ from stats_arrays import uncertainty_choices
+
+ raw_description = uncertainty_choices[0].description
+ monkeypatch.setattr(
+ "activity_browser.ui.tables.delegates.uncertainty._",
+ lambda value: "无不确定性" if value == raw_description else value,
+ )
+ delegate = UncertaintyDelegate()
+
+ assert delegate.displayText(0, None) == "无不确定性"
+ assert delegate.choices[raw_description] == 0
+
+
+def test_uncertainty_delegate_stores_item_id_not_translated_text(qtbot):
+ from stats_arrays import uncertainty_choices
+
+ choice = next(item for item in uncertainty_choices if item.id > 0)
+ editor = QtWidgets.QComboBox()
+ qtbot.addWidget(editor)
+ editor.addItem("已翻译的不确定性类型", choice.id)
+
+ model = QStandardItemModel(1, 1)
+ index = model.index(0, 0)
+ UncertaintyDelegate().setModelData(editor, model, index)
+
+ assert model.data(index, Qt.EditRole) == choice.id
diff --git a/tests/ui/test_ui_translation_coverage.py b/tests/ui/test_ui_translation_coverage.py
new file mode 100644
index 000000000..9dcd71414
--- /dev/null
+++ b/tests/ui/test_ui_translation_coverage.py
@@ -0,0 +1,572 @@
+import ast
+import json
+import re
+from collections import Counter
+from pathlib import Path
+from string import Formatter
+
+
+ROOT = Path(__file__).resolve().parents[2]
+SOURCE_ROOTS = (
+ ROOT / "activity_browser" / "layouts",
+ ROOT / "activity_browser" / "ui",
+)
+EXTRA_SOURCE_PATHS = (ROOT / "activity_browser" / "settings.py",)
+CATALOG_DIR = ROOT / "activity_browser" / "translations" / "zh_CN"
+
+ENGLISH_WORD = re.compile(r"[A-Za-z]{2,}")
+
+# These strings are intentionally displayed verbatim. The first group is a
+# product name; the second consists of file-format labels and button symbols;
+# the third contains scientific/database identifiers or data placeholders.
+BRAND_LITERALS = {
+ "Activity Browser",
+ "Activity Browser - {}",
+}
+FORMAT_LITERALS = {
+ ".csv",
+ ".png",
+ ".svg",
+ "PNG (*.png)",
+ "SVG (*.svg)",
+}
+DATA_LITERALS = {
+ "Biosphere3",
+ "Europe without Switzerland",
+ "Forwast",
+ "RER",
+ "RoW",
+ "nan",
+}
+DIRECT_LITERAL_WHITELIST = BRAND_LITERALS | FORMAT_LITERALS | DATA_LITERALS
+
+TEXT_CONSTRUCTORS = {
+ "QAction",
+ "QCheckBox",
+ "QCommandLinkButton",
+ "QDockWidget",
+ "QGroupBox",
+ "QLabel",
+ "QLineEdit",
+ "QMenu",
+ "QProgressDialog",
+ "QPushButton",
+ "QRadioButton",
+ "QStandardItem",
+ "QTableWidgetItem",
+ "QTreeWidgetItem",
+ "QListWidgetItem",
+ "QToolButton",
+}
+TEXT_METHODS = {
+ "addAction",
+ "addItem",
+ "addItems",
+ "addMenu",
+ "addTab",
+ "get_header_layout",
+ "get_header_layout_w_help",
+ "header",
+ "insertItem",
+ "insertTab",
+ "setAccessibleDescription",
+ "setAccessibleName",
+ "setButtonText",
+ "setDescription",
+ "setDetailedText",
+ "setFormat",
+ "setHeaderLabels",
+ "setHorizontalHeaderLabels",
+ "setIconText",
+ "setInformativeText",
+ "setItemText",
+ "setLabelText",
+ "setNameFilter",
+ "setNameFilters",
+ "setPlaceholderText",
+ "setPlainText",
+ "setPrefix",
+ "setStatusTip",
+ "setSuffix",
+ "setSubTitle",
+ "setTabText",
+ "setText",
+ "setTitle",
+ "setToolTip",
+ "setVerticalHeaderLabels",
+ "setWhatsThis",
+ "setWindowTitle",
+ "setHtml",
+ "setMarkdown",
+ "showMessage",
+}
+MESSAGE_METHODS = {"about", "critical", "information", "question", "warning"}
+FILE_DIALOG_METHODS = {
+ "getExistingDirectory",
+ "getOpenFileName",
+ "getOpenFileNames",
+ "getSaveFileName",
+}
+INPUT_DIALOG_METHODS = {"getDouble", "getInt", "getItem", "getText"}
+PLOT_FIRST_ARGUMENT_METHODS = {
+ "annotate",
+ "set_title",
+ "set_xlabel",
+ "set_ylabel",
+ "suptitle",
+}
+PLOT_LABEL_METHODS = {"axhline", "axvline", "bar", "barh", "hist", "plot"}
+
+UPSTREAM_UNCERTAINTY_DESCRIPTIONS = {
+ "Bernoulli uncertainty",
+ "Beta PERT uncertainty",
+ "Beta uncertainty",
+ "Discrete uniform uncertainty",
+ "Gamma uncertainty",
+ "Generalized extreme value uncertainty",
+ "Lognormal uncertainty",
+ "No uncertainty",
+ "Normal uncertainty",
+ "Student's T uncertainty",
+ "Triangular uncertainty",
+ "Undefined or unknown uncertainty",
+ # Older supported stats_arrays releases used this shorter description.
+ "Undefined uncertainty",
+ "Uniform uncertainty",
+ "Weibull uncertainty",
+}
+
+INDIRECT_UI_SOURCES = UPSTREAM_UNCERTAINTY_DESCRIPTIONS | {
+ # These labels are deliberately stored as raw IDs or source-library
+ # metadata, then translated only where Qt displays them.
+ "No classification",
+ "All Files (*.*)",
+ "CSV (*.csv);; All Files (*.*)",
+ "TSV (*.tsv);; All Files (*.*)",
+ "Excel (*.xlsx);; All Files (*.*)",
+}
+
+# These declared columns are intentionally not localized. ``cf`` is hidden and
+# stores the characterization-factor object; the ISIC text is the official
+# scientific classification-system name shown verbatim.
+RAW_DECLARED_HEADER_LITERALS = {"cf", "ISIC rev.4 ecoinvent"}
+
+
+def source_paths():
+ return sorted(
+ [path for root in SOURCE_ROOTS for path in root.rglob("*.py")]
+ + list(EXTRA_SOURCE_PATHS)
+ )
+
+
+def qualified_name(node):
+ if isinstance(node, ast.Name):
+ return node.id
+ if isinstance(node, ast.Attribute):
+ prefix = qualified_name(node.value)
+ return f"{prefix}.{node.attr}" if prefix else node.attr
+ return ""
+
+
+def is_translation_call(node):
+ return (
+ isinstance(node, ast.Call)
+ and qualified_name(node.func).rsplit(".", 1)[-1] == "_"
+ )
+
+
+def direct_strings(node):
+ """Return fixed strings that reach a UI sink without passing through `_`."""
+
+ if is_translation_call(node):
+ return []
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ return [node.value]
+ if isinstance(node, ast.JoinedStr):
+ return [
+ "".join(
+ value.value
+ if isinstance(value, ast.Constant)
+ and isinstance(value.value, str)
+ else "{}"
+ for value in node.values
+ )
+ ]
+ if isinstance(node, (ast.List, ast.Set, ast.Tuple)):
+ return [value for item in node.elts for value in direct_strings(item)]
+ if isinstance(node, ast.IfExp):
+ return direct_strings(node.body) + direct_strings(node.orelse)
+ if isinstance(node, ast.BinOp):
+ return direct_strings(node.left) + direct_strings(node.right)
+ return []
+
+
+def text_arguments(call):
+ name = qualified_name(call.func).rsplit(".", 1)[-1]
+ qualified = qualified_name(call.func)
+
+ if name in TEXT_CONSTRUCTORS or name in TEXT_METHODS:
+ return list(call.args) + [
+ keyword.value
+ for keyword in call.keywords
+ if keyword.arg in {"label", "text", "title"}
+ ]
+ if name == "QMessageBox":
+ return list(call.args[1:3])
+ if name in MESSAGE_METHODS and "QMessageBox" in qualified:
+ return list(call.args[1:3])
+ if name in FILE_DIALOG_METHODS:
+ positional = [
+ argument
+ for index, argument in enumerate(call.args)
+ if index in {1, 3}
+ ]
+ keyword = [
+ item.value
+ for item in call.keywords
+ if item.arg in {"caption", "filter", "selectedFilter"}
+ ]
+ return positional + keyword
+ if name in INPUT_DIALOG_METHODS and "QInputDialog" in qualified:
+ return list(call.args[1:3])
+ if name in PLOT_FIRST_ARGUMENT_METHODS:
+ return list(call.args[:1])
+ if name == "text":
+ # matplotlib Axes.text(x, y, text, ...)
+ return list(call.args[2:3])
+ if name in PLOT_LABEL_METHODS:
+ return [item.value for item in call.keywords if item.arg == "label"]
+ return []
+
+
+def merged_catalog():
+ merged = {}
+ origins = {}
+ conflicts = []
+ for path in sorted(CATALOG_DIR.glob("*.json")):
+ fragment = json.loads(path.read_text(encoding="utf-8"))
+ for source, translation in fragment.items():
+ if source in merged and merged[source] != translation:
+ conflicts.append((source, origins[source], path.name))
+ merged[source] = translation
+ origins[source] = path.name
+ return merged, conflicts
+
+
+def format_signature(value):
+ return Counter(
+ (field_name, format_spec, conversion)
+ for _, field_name, format_spec, conversion in Formatter().parse(value)
+ if field_name is not None
+ )
+
+
+def static_translation_sources(tree):
+ sources = set()
+ for node in ast.walk(tree):
+ if not is_translation_call(node) or not node.args:
+ continue
+ try:
+ source = ast.literal_eval(node.args[0])
+ except (TypeError, ValueError):
+ continue
+ if isinstance(source, str):
+ sources.add(source)
+ return sources
+
+
+def test_known_ui_text_entries_do_not_receive_fixed_untranslated_english():
+ untranslated = []
+ for path in source_paths():
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ for argument in text_arguments(node):
+ for value in direct_strings(argument):
+ if (
+ ENGLISH_WORD.search(value)
+ and value not in DIRECT_LITERAL_WHITELIST
+ ):
+ untranslated.append(
+ (
+ str(path.relative_to(ROOT)),
+ node.lineno,
+ qualified_name(node.func),
+ value,
+ )
+ )
+ assert untranslated == []
+
+
+class TranslationScopeVisitor(ast.NodeVisitor):
+ """Find gettext calls and assignments to `_` in one Python function scope."""
+
+ def __init__(self, root):
+ self.root = root
+ self.calls = []
+ self.stores = []
+
+ def visit_FunctionDef(self, node):
+ if node is self.root:
+ self.generic_visit(node)
+
+ def visit_AsyncFunctionDef(self, node):
+ if node is self.root:
+ self.generic_visit(node)
+
+ def visit_Lambda(self, node):
+ return
+
+ def _visit_comprehension(self, node):
+ # Comprehension targets have their own scope in Python 3. Expressions
+ # and iterable sources can still load names from the surrounding scope.
+ if hasattr(node, "elt"):
+ self.visit(node.elt)
+ else:
+ self.visit(node.key)
+ self.visit(node.value)
+ for generator in node.generators:
+ self.visit(generator.iter)
+ for condition in generator.ifs:
+ self.visit(condition)
+
+ visit_ListComp = _visit_comprehension
+ visit_SetComp = _visit_comprehension
+ visit_DictComp = _visit_comprehension
+ visit_GeneratorExp = _visit_comprehension
+
+ def visit_Name(self, node):
+ if node.id == "_" and isinstance(node.ctx, (ast.Del, ast.Store)):
+ self.stores.append(node.lineno)
+
+ def visit_Call(self, node):
+ if isinstance(node.func, ast.Name) and node.func.id == "_":
+ self.calls.append(node.lineno)
+ self.generic_visit(node)
+
+
+def test_gettext_name_is_not_shadowed_in_the_same_function_scope():
+ shadowing = []
+ for path in source_paths():
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for function in ast.walk(tree):
+ if not isinstance(function, (ast.AsyncFunctionDef, ast.FunctionDef)):
+ continue
+ visitor = TranslationScopeVisitor(function)
+ visitor.visit(function)
+ if visitor.calls and visitor.stores:
+ shadowing.append(
+ (
+ str(path.relative_to(ROOT)),
+ function.name,
+ visitor.stores,
+ visitor.calls,
+ )
+ )
+ assert shadowing == []
+
+
+def test_layout_and_ui_translation_sources_are_catalogued_safely():
+ catalog, conflicts = merged_catalog()
+ assert conflicts == []
+
+ sources = set()
+ for path in source_paths():
+ sources.update(
+ static_translation_sources(ast.parse(path.read_text(encoding="utf-8")))
+ )
+ sources.update(INDIRECT_UI_SOURCES)
+ assert sources.difference(catalog) == set()
+
+ for source, translation in catalog.items():
+ assert format_signature(source) == format_signature(translation), source
+ assert re.findall(r'href="([^"]+)"', source) == re.findall(
+ r'href="([^"]+)"', translation
+ ), source
+
+
+def enclosing_display_role_branch(node, parents, function):
+ current = node
+ while current in parents and parents[current] is not function:
+ current = parents[current]
+ if isinstance(current, ast.If):
+ if any(
+ isinstance(part, ast.Attribute) and part.attr == "DisplayRole"
+ for part in ast.walk(current.test)
+ ):
+ return True
+ return False
+
+
+def assignment_names(node):
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
+ return {
+ target.id
+ for target in targets
+ if isinstance(target, ast.Name)
+ }
+
+
+def test_model_headers_are_raw_in_data_and_translated_only_for_display_role():
+ model_root = ROOT / "activity_browser" / "ui" / "tables" / "models"
+ catalog, _conflicts = merged_catalog()
+ translated_header_functions = 0
+ data_declarations = {
+ "COLUMNS",
+ "HEADERS",
+ "RESULT_METADATA_HEADERS",
+ "TRANSLATABLE_HEADERS",
+ "TRANSLATABLE_VALUES",
+ "UNCERTAINTY",
+ }
+
+ for path in sorted(model_root.rglob("*.py")):
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ parents = {
+ child: parent
+ for parent in ast.walk(tree)
+ for child in ast.iter_child_nodes(parent)
+ }
+
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.Assign, ast.AnnAssign)) and (
+ assignment_names(node) & data_declarations
+ ):
+ assert not any(
+ is_translation_call(part) for part in ast.walk(node.value)
+ ), (path, node.lineno)
+ declared_literals = {
+ part.value
+ for part in ast.walk(node.value)
+ if isinstance(part, ast.Constant)
+ and isinstance(part.value, str)
+ }
+ assert declared_literals.difference(
+ catalog, RAW_DECLARED_HEADER_LITERALS
+ ) == set(), (path, node.lineno)
+
+ if not isinstance(node, ast.FunctionDef) or node.name != "headerData":
+ continue
+ calls = [part for part in ast.walk(node) if is_translation_call(part)]
+ if calls:
+ translated_header_functions += 1
+ assert all(
+ enclosing_display_role_branch(call, parents, node) for call in calls
+ ), (path, node.lineno)
+
+ assert translated_header_functions >= 2
+
+ base_path = model_root / "base.py"
+ base_tree = ast.parse(base_path.read_text(encoding="utf-8"))
+ pandas_model = next(
+ node
+ for node in base_tree.body
+ if isinstance(node, ast.ClassDef) and node.name == "PandasModel"
+ )
+ export_methods = {
+ node.name: node
+ for node in pandas_model.body
+ if isinstance(node, ast.FunctionDef)
+ and node.name in {"to_clipboard", "to_csv", "to_excel"}
+ }
+ assert set(export_methods) == {"to_clipboard", "to_csv", "to_excel"}
+ assert not any(
+ is_translation_call(part)
+ for method in export_methods.values()
+ for part in ast.walk(method)
+ )
+
+ base_source = base_path.read_text(encoding="utf-8")
+ inventory_view_source = (
+ ROOT / "activity_browser" / "ui" / "tables" / "inventory.py"
+ ).read_text(encoding="utf-8")
+ assert "role in (Qt.DisplayRole, Qt.UserRole)" in base_source
+ assert ".data(QtCore.Qt.UserRole)" in inventory_view_source
+
+
+def test_humanized_dates_follow_the_interface_language():
+ base_path = (
+ ROOT / "activity_browser" / "ui" / "tables" / "models" / "base.py"
+ )
+ tree = ast.parse(base_path.read_text(encoding="utf-8"))
+ calls = [
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "humanize"
+ ]
+ assert len(calls) == 1
+
+ locale_keywords = [
+ keyword.value for keyword in calls[0].keywords if keyword.arg == "locale"
+ ]
+ assert len(locale_keywords) == 1
+ locale_expression = locale_keywords[0]
+ assert any(
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "current_language"
+ for node in ast.walk(locale_expression)
+ )
+ assert {
+ node.func.attr
+ for node in ast.walk(locale_expression)
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
+ } >= {"replace", "lower"}
+
+
+def test_display_labels_are_not_reused_as_table_logic_keys():
+ impact_path = (
+ ROOT / "activity_browser" / "ui" / "tables" / "impact_categories.py"
+ )
+ impact_tree = ast.parse(impact_path.read_text(encoding="utf-8"))
+ cf_table = next(
+ node
+ for node in impact_tree.body
+ if isinstance(node, ast.ClassDef)
+ and node.name == "MethodCharacterizationFactorsTable"
+ )
+ cell_edited = next(
+ node
+ for node in cf_table.body
+ if isinstance(node, ast.FunctionDef) and node.name == "cell_edited"
+ )
+ assert not any(
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "headerData"
+ for node in ast.walk(cell_edited)
+ )
+ assert any(
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "index"
+ and isinstance(node.func.value, ast.Attribute)
+ and node.func.value.attr == "HEADERS"
+ and len(node.args) == 1
+ and isinstance(node.args[0], ast.Constant)
+ and node.args[0].value == "Amount"
+ for node in ast.walk(cell_edited)
+ )
+
+ views_source = (
+ ROOT / "activity_browser" / "ui" / "tables" / "views.py"
+ ).read_text(encoding="utf-8")
+ dialog_source = (
+ ROOT / "activity_browser" / "ui" / "widgets" / "dialog.py"
+ ).read_text(encoding="utf-8")
+ assert "column_names = self.model.filterable_columns" in views_source
+ assert "column_labels=column_labels" in views_source
+ assert "column_label=column_label" in views_source
+ assert "tab_id = self.col_id_2_tab_id[selected_column]" in dialog_source
+ assert "self.tabs[tab_id].filter_rows" in dialog_source
+
+ # Plot preparation must also avoid label-based dropping, which would remove
+ # every user row with a reserved-looking name.
+ figures_source = (ROOT / "activity_browser" / "ui" / "figures.py").read_text(
+ encoding="utf-8"
+ )
+ assert '.drop("Score"' not in figures_source
+ assert "fixed_rest_positions" in figures_source
diff --git a/tests/ui/web/test_web_localization.py b/tests/ui/web/test_web_localization.py
new file mode 100644
index 000000000..d689fd918
--- /dev/null
+++ b/tests/ui/web/test_web_localization.py
@@ -0,0 +1,133 @@
+"""Localization checks for embedded Activity Browser web views."""
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+from activity_browser.ui.web import base, navigator, sankey_navigator, webutils
+
+
+STATIC_DIR = Path(__file__).resolve().parents[3] / "activity_browser" / "static"
+
+
+def test_localized_html_path_prefers_requested_language(tmp_path):
+ default = tmp_path / "welcome.html"
+ chinese = tmp_path / "welcome.zh_CN.html"
+ default.write_text("English", encoding="utf-8")
+ chinese.write_text("中文", encoding="utf-8")
+
+ assert webutils.localized_html_path(str(default), "zh_CN") == str(chinese)
+ assert webutils.localized_html_path(str(default), "en_US") == str(default)
+
+
+def test_welcome_pages_are_utf8_and_chinese_page_is_complete():
+ english = (STATIC_DIR / "startscreen" / "welcome.html").read_text(encoding="utf-8")
+ chinese = (STATIC_DIR / "startscreen" / "welcome.zh_CN.html").read_text(
+ encoding="utf-8"
+ )
+
+ assert 'charset="utf-8"' in english
+ assert "ISO-8859-1" not in english
+ assert 'lang="zh-CN"' in chinese
+ assert "欢迎使用 Activity Browser" in chinese
+ assert "生命周期评价" in chinese
+ assert "参与贡献" in chinese
+
+
+def test_graph_html_injects_only_fixed_ui_translations(monkeypatch):
+ translations = {
+ "Graph Navigator": "图形浏览器",
+ "Reset Zoom": "重置缩放",
+ "Download SVG": "下载 SVG",
+ "Individual impact": "单项影响",
+ "Cumulative impact": "累积影响",
+ }
+ monkeypatch.setattr(base, "_", lambda source: translations.get(source, source))
+ monkeypatch.setattr(base, "current_language", lambda: "zh_CN")
+ page = SimpleNamespace(
+ HTML_FILE=str(STATIC_DIR / "navigator.html"),
+ PAGE_TITLE="Graph Navigator",
+ )
+
+ rendered = base.BaseNavigatorWidget.render_html(page)
+
+ assert '' in rendered
+ assert '
图形浏览器
' in rendered
+ assert ">重置缩放" in rendered
+ assert ">下载 SVG" in rendered
+ assert "{{" not in rendered
+ embedded = rendered.split("window.abTranslations = ", 1)[1].split(";", 1)[0]
+ assert json.loads(embedded) == {
+ "individual_impact": "单项影响",
+ "cumulative_impact": "累积影响",
+ }
+ # Local JavaScript assets remain referenced, rather than being translated.
+ assert 'src="javascript/d3.js"' in rendered
+
+
+def test_graph_mode_does_not_depend_on_translated_button_text():
+ holder = SimpleNamespace(_expansion_mode=True)
+ assert navigator.GraphNavigatorWidget.is_expansion_mode.fget(holder)
+ holder._expansion_mode = False
+ assert not navigator.GraphNavigatorWidget.is_expansion_mode.fget(holder)
+
+
+def test_sankey_title_translates_labels_but_preserves_scientific_data(monkeypatch):
+ source = (
+ "Reference flow: {amount:.2g} {unit} {product} | {activity} | "
+ "{location}
Total impact: {impact:.2g} {impact_unit}"
+ )
+ chinese = (
+ "参考流:{amount:.2g} {unit} {product} | {activity} | {location} "
+ "
总影响:{impact:.2g} {impact_unit}"
+ )
+ monkeypatch.setattr(
+ sankey_navigator, "_", lambda value: chinese if value == source else value
+ )
+
+ activity = SimpleNamespace(
+ get=lambda field: {
+ "unit": "kilogram",
+ "reference product": "market for electricity",
+ "name": "electricity production, wind",
+ "location": "CN",
+ }.get(field)
+ )
+ title = sankey_navigator.Graph.build_title((activity, 2.0), 3.5, "kg CO2-Eq")
+
+ assert title.startswith("参考流:")
+ assert "market for electricity" in title
+ assert "electricity production, wind" in title
+ assert "kg CO2-Eq" in title
+ assert "总影响:" in title
+
+
+def test_exchange_tooltip_translates_connector_but_preserves_data(monkeypatch):
+ source = "{amount:.3g} {unit} of {product}"
+ chinese = "{product}:{amount:.3g} {unit}"
+ monkeypatch.setattr(
+ navigator, "_", lambda value: chinese if value == source else value
+ )
+
+ input_activity = SimpleNamespace(
+ key=("ecoinvent", "input"),
+ get=lambda field: {
+ "reference product": "electricity, high voltage",
+ "name": "electricity production",
+ }.get(field),
+ )
+ output_activity = SimpleNamespace(key=("ecoinvent", "output"))
+
+ class Exchange:
+ input = input_activity
+ output = output_activity
+
+ @staticmethod
+ def get(field, default=None):
+ return {"amount": 1.25, "unit": "kilowatt hour"}.get(field, default)
+
+ edge = navigator.Graph.build_json_edge(Exchange(), flip_negative=False)
+
+ assert edge["product"] == "electricity, high voltage"
+ assert edge["unit"] == "kilowatt hour"
+ assert edge["tooltip"] == ("electricity, high voltage:1.25 kilowatt hour")
diff --git a/tests/ui/widgets/test_dialog_i18n.py b/tests/ui/widgets/test_dialog_i18n.py
new file mode 100644
index 000000000..04900b564
--- /dev/null
+++ b/tests/ui/widgets/test_dialog_i18n.py
@@ -0,0 +1,126 @@
+import ast
+import json
+from pathlib import Path
+from string import Formatter
+
+
+ROOT = Path(__file__).resolve().parents[3]
+SOURCE_PATH = ROOT / "activity_browser" / "ui" / "widgets" / "dialog.py"
+CATALOG_PATH = (
+ ROOT / "activity_browser" / "translations" / "zh_CN" / "dialogs.json"
+)
+
+
+def source_tree():
+ return ast.parse(SOURCE_PATH.read_text(encoding="utf-8"))
+
+
+def catalog():
+ return json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
+
+
+def literal_translation_sources(tree):
+ sources = set()
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ and node.args
+ ):
+ continue
+ try:
+ source = ast.literal_eval(node.args[0])
+ except (TypeError, ValueError):
+ continue
+ if isinstance(source, str):
+ sources.add(source)
+ return sources
+
+
+def format_fields(value):
+ return {
+ field_name
+ for _, field_name, _, _ in Formatter().parse(value)
+ if field_name is not None
+ }
+
+
+def test_dialog_catalog_covers_every_literal_translation_call():
+ assert set(catalog()) == literal_translation_sources(source_tree())
+
+
+def test_dialog_catalog_preserves_placeholders_and_scientific_data():
+ translations = catalog()
+ for source, translation in translations.items():
+ assert format_fields(source) == format_fields(translation), source
+
+ # These are location/data identifiers and must not become translation keys.
+ for data_name in ("RoW", "RER", "Europe without Switzerland", "biosphere3"):
+ assert data_name not in translations
+
+ source = SOURCE_PATH.read_text(encoding="utf-8")
+ assert 'QtWidgets.QCheckBox("RoW")' in source
+ assert 'QtWidgets.QCheckBox("RER")' in source
+ assert 'QtWidgets.QCheckBox("Europe without Switzerland")' in source
+ assert "self.options.addItems(sort_semantic_versions(__ei_versions__))" in source
+ assert 'QtWidgets.QPushButton(act.as_dict()["name"])' in source
+
+
+def test_dialog_display_labels_do_not_replace_stable_item_data():
+ source = SOURCE_PATH.read_text(encoding="utf-8")
+ assert "self.field_separator.addItem(label, separator)" in source
+ assert "self.filter_type_box.addItem(label, filter_id)" in source
+ assert "selected_type = self.filter_type_box.currentData()" in source
+ assert 'self.AND.setProperty("filter_mode", FILTER_MODE_AND)' in source
+ assert 'self.OR.setProperty("filter_mode", FILTER_MODE_OR)' in source
+ assert 'checkedButton().property("filter_mode")' in source
+
+
+def test_fixed_dialog_widget_text_is_not_left_as_a_direct_literal():
+ tree = source_tree()
+ text_constructors = {
+ "QCheckBox",
+ "QGroupBox",
+ "QLabel",
+ "QPushButton",
+ "QRadioButton",
+ }
+ text_methods = {
+ "setButtonText": 1,
+ "setPlaceholderText": 0,
+ "setText": 0,
+ "setToolTip": 0,
+ "setWindowTitle": 0,
+ }
+ allowed_data_literals = {"RoW", "RER", "Europe without Switzerland"}
+ untranslated = []
+
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
+ continue
+ function_name = node.func.attr
+ candidate_arguments = []
+
+ if function_name in text_constructors and node.args:
+ candidate_arguments.append(node.args[0])
+ elif function_name in text_methods and len(node.args) > text_methods[function_name]:
+ candidate_arguments.append(node.args[text_methods[function_name]])
+
+ if function_name == "getOpenFileName":
+ candidate_arguments.extend(
+ keyword.value
+ for keyword in node.keywords
+ if keyword.arg in {"caption", "filter", "selectedFilter"}
+ )
+
+ for argument in candidate_arguments:
+ if (
+ isinstance(argument, ast.Constant)
+ and isinstance(argument.value, str)
+ and argument.value
+ and argument.value not in allowed_data_literals
+ ):
+ untranslated.append((node.lineno, argument.value))
+
+ assert untranslated == []
diff --git a/tests/ui/wizards/test_db_import_wizard_i18n.py b/tests/ui/wizards/test_db_import_wizard_i18n.py
new file mode 100644
index 000000000..4a74a624f
--- /dev/null
+++ b/tests/ui/wizards/test_db_import_wizard_i18n.py
@@ -0,0 +1,158 @@
+import ast
+import json
+from pathlib import Path
+from string import Formatter
+
+
+ROOT = Path(__file__).resolve().parents[3]
+SOURCE_PATH = ROOT / "activity_browser" / "ui" / "wizards" / "db_import_wizard.py"
+CATALOG_PATH = (
+ ROOT
+ / "activity_browser"
+ / "translations"
+ / "zh_CN"
+ / "wizard_import.json"
+)
+
+
+def source_tree():
+ return ast.parse(SOURCE_PATH.read_text(encoding="utf-8"))
+
+
+def catalog():
+ return json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
+
+
+def literal_translation_sources(tree):
+ sources = set()
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ and node.args
+ ):
+ continue
+ try:
+ source = ast.literal_eval(node.args[0])
+ except (ValueError, TypeError):
+ continue
+ if isinstance(source, str):
+ sources.add(source)
+ return sources
+
+
+def option_labels(tree):
+ labels = set()
+ page_names = {"ImportTypePage", "RemoteImportPage", "LocalImportPage"}
+ for class_node in (
+ node for node in tree.body if isinstance(node, ast.ClassDef)
+ ):
+ if class_node.name not in page_names:
+ continue
+ options_node = next(
+ node
+ for node in class_node.body
+ if isinstance(node, ast.Assign)
+ and any(
+ isinstance(target, ast.Name) and target.id == "OPTIONS"
+ for target in node.targets
+ )
+ )
+ labels.update(option.elts[0].value for option in options_node.value.elts)
+ # Forwast is a database/proper name and deliberately bypasses translation.
+ labels.remove("Forwast")
+ return labels
+
+
+def format_fields(value):
+ return {
+ field_name
+ for _, field_name, _, _ in Formatter().parse(value)
+ if field_name is not None
+ }
+
+
+def test_import_wizard_catalog_covers_all_translation_calls():
+ tree = source_tree()
+ expected = literal_translation_sources(tree) | option_labels(tree)
+
+ assert set(catalog()) == expected
+
+
+def test_import_wizard_catalog_preserves_format_fields_and_data_names():
+ translations = catalog()
+ for source, translation in translations.items():
+ assert format_fields(source) == format_fields(translation), source
+
+ # Scientific/data identifiers remain source data, not translation keys.
+ assert "Forwast" not in translations
+ assert "biosphere3" not in translations
+ assert "cutoff" not in translations
+ assert "consequential" not in translations
+
+ source = SOURCE_PATH.read_text(encoding="utf-8")
+ assert 'option[0] if option[1] == "forwast" else _(option[0])' in source
+ assert "return self.ecoinvent_version_page.version_combobox.currentText()" in source
+ assert "return self.ecoinvent_version_page.system_model_combobox.currentText()" in source
+
+
+def test_fixed_widget_text_is_not_left_as_direct_string_literals():
+ tree = source_tree()
+ text_constructors = {"QGroupBox", "QLabel", "QPushButton", "QRadioButton"}
+ text_methods = {
+ "setButtonText": 1,
+ "setPlaceholderText": 0,
+ "setSubTitle": 0,
+ "setText": 0,
+ "setTitle": 0,
+ "setWindowTitle": 0,
+ }
+ untranslated = []
+
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
+ continue
+ function_name = node.func.attr
+ text_arg = None
+ if function_name in text_constructors and node.args:
+ text_arg = node.args[0]
+ elif function_name in text_methods and len(node.args) > text_methods[function_name]:
+ text_arg = node.args[text_methods[function_name]]
+
+ if function_name in {"getExistingDirectory", "getOpenFileName"}:
+ caption = next(
+ (keyword.value for keyword in node.keywords if keyword.arg == "caption"),
+ None,
+ )
+ if caption is None and len(node.args) > 1:
+ caption = node.args[1]
+ text_arg = caption
+ for keyword in node.keywords:
+ if (
+ keyword.arg == "filter"
+ and isinstance(keyword.value, ast.Constant)
+ and isinstance(keyword.value.value, str)
+ and keyword.value.value
+ ):
+ untranslated.append((node.lineno, keyword.value.value))
+
+ # QMessageBox titles and messages are the second and third arguments.
+ if function_name in {"information", "question", "warning"}:
+ for argument in node.args[1:3]:
+ if (
+ isinstance(argument, ast.Constant)
+ and isinstance(argument.value, str)
+ and argument.value
+ ):
+ untranslated.append((node.lineno, argument.value))
+
+ if (
+ isinstance(text_arg, ast.Constant)
+ and isinstance(text_arg.value, str)
+ and text_arg.value
+ and text_arg.value != "Forwast"
+ ):
+ untranslated.append((node.lineno, text_arg.value))
+
+ assert untranslated == []
diff --git a/tests/wizards/test_misc_localization.py b/tests/wizards/test_misc_localization.py
new file mode 100644
index 000000000..f99edc2a4
--- /dev/null
+++ b/tests/wizards/test_misc_localization.py
@@ -0,0 +1,121 @@
+import ast
+import json
+import string
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+WIZARD_DIR = ROOT / "activity_browser" / "ui" / "wizards"
+WIZARD_FILES = tuple(
+ WIZARD_DIR / name
+ for name in (
+ "uncertainty.py",
+ "db_export_wizard.py",
+ "project_setup_wizard.py",
+ "plugins_manager_wizard.py",
+ )
+)
+CATALOG_FILE = (
+ ROOT / "activity_browser" / "translations" / "zh_CN" / "wizards_misc.json"
+)
+
+
+def _literal_translation_sources(path):
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ sources = set()
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_"
+ and node.args
+ ):
+ continue
+ try:
+ source = ast.literal_eval(node.args[0])
+ except (ValueError, TypeError):
+ continue
+ if isinstance(source, str):
+ sources.add(source)
+ return sources
+
+
+def test_misc_wizard_catalog_covers_fixed_ui_sources():
+ catalog = json.loads(CATALOG_FILE.read_text(encoding="utf-8"))
+ sources = set().union(
+ *(_literal_translation_sources(path) for path in WIZARD_FILES)
+ )
+ # These filters are selected by a stable exporter ID before translation.
+ sources.update(
+ {
+ "BW2Package Files (*.bw2package);; All Files (*.*)",
+ "Excel Files (*.xlsx);; All Files (*.*)",
+ }
+ )
+
+ assert sources <= catalog.keys()
+
+
+def test_misc_wizard_translation_placeholders_match_sources():
+ catalog = json.loads(CATALOG_FILE.read_text(encoding="utf-8"))
+ formatter = string.Formatter()
+
+ def fields(value):
+ return sorted(
+ field for _, field, _, _ in formatter.parse(value) if field is not None
+ )
+
+ for source, translation in catalog.items():
+ assert fields(source) == fields(translation), source
+
+
+def test_misc_wizard_catalog_has_no_conflicts_with_other_fragments():
+ catalog_dir = CATALOG_FILE.parent
+ merged = {}
+ for path in sorted(catalog_dir.glob("*.json")):
+ fragment = json.loads(path.read_text(encoding="utf-8"))
+ for source, translation in fragment.items():
+ assert source not in merged or merged[source] == translation, source
+ merged[source] = translation
+
+
+def test_dynamic_and_scientific_values_are_not_translated():
+ uncertainty_source = (WIZARD_DIR / "uncertainty.py").read_text(encoding="utf-8")
+ export_source = (WIZARD_DIR / "db_export_wizard.py").read_text(encoding="utf-8")
+ setup_source = (WIZARD_DIR / "project_setup_wizard.py").read_text(encoding="utf-8")
+
+ # stats_arrays descriptions are fixed UI metadata. Localizing them does
+ # not affect calculations because the selected combobox index still maps
+ # directly to the distribution ID.
+ assert "[_(ud.description) for ud in uncertainty.choices]" in uncertainty_source
+ assert (
+ "self.dist = uncertainty.id_dict[self.distribution.currentIndex()]"
+ in uncertainty_source
+ )
+ assert "self.export_option.addItem(exporter_id, exporter_id)" in export_source
+ assert "self.versions.addItems(release.list_versions())" in setup_source
+ assert "self.models.addItems(release.list_system_models(version))" in setup_source
+
+
+def test_display_text_is_not_used_as_an_internal_choice():
+ for path in WIZARD_FILES:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Compare):
+ continue
+ reads_display_text = any(
+ isinstance(child, ast.Call)
+ and isinstance(child.func, ast.Attribute)
+ and child.func.attr in {"text", "currentText"}
+ for child in ast.walk(node)
+ )
+ if not reads_display_text:
+ continue
+ compared_strings = {
+ child.value
+ for child in ast.walk(node)
+ if isinstance(child, ast.Constant) and isinstance(child.value, str)
+ }
+ # Choices use indexes, button IDs, fields, itemData, or other stable
+ # technical IDs; translated labels never determine behavior.
+ assert not compared_strings
diff --git a/tests/wizards/test_settings_wizard_language.py b/tests/wizards/test_settings_wizard_language.py
new file mode 100644
index 000000000..0e5f5e1a2
--- /dev/null
+++ b/tests/wizards/test_settings_wizard_language.py
@@ -0,0 +1,50 @@
+from activity_browser.ui.wizards import settings_wizard
+
+
+class FakeSettings:
+ current_bw_dir = "/brightway"
+ custom_bw_dir = ["/brightway"]
+ startup_project = "default"
+ language = "system"
+
+ def __init__(self):
+ self.write_count = 0
+
+ def write_settings(self):
+ self.write_count += 1
+
+
+class FakeComboBox:
+ def currentData(self):
+ return "zh_CN"
+
+ def currentText(self):
+ # A translated display value must never be written as program state.
+ return "简体中文"
+
+
+class FakePage:
+ language_combo = FakeComboBox()
+
+
+class FakeWizard:
+ settings_page = FakePage()
+
+ def field(self, name):
+ return {"current_bw_dir": "/brightway", "startup_project": "default"}[name]
+
+
+def test_settings_wizard_saves_language_item_data(monkeypatch):
+ fake_settings = FakeSettings()
+ switched_directories = []
+ monkeypatch.setattr(settings_wizard, "ab_settings", fake_settings)
+ monkeypatch.setattr(
+ settings_wizard.projects, "switch_dir", switched_directories.append
+ )
+
+ settings_wizard.SettingsWizard.save_settings(FakeWizard())
+
+ assert fake_settings.language == "zh_CN"
+ assert fake_settings.language != "简体中文"
+ assert fake_settings.write_count == 1
+ assert switched_directories == ["/brightway"]