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.
The first five are provided. ' - f"If you want to save the dataframe you can either save those scenario exchanges where relinking failed " - f"(check the excerpt box), or save the entire dataframe with a new column indicating failed relinking." - f"
To abort the process press 'Cancel'" - ) critical_message = ABPopup.abCritical( - "Activities not found", - msg, - QPushButton("Save"), - QPushButton("Cancel"), + _("Activities not found"), + _( + "Multiple activities could not be relinked to the local database.
" + "The first five are shown. You can save only the failed scenario " + "exchanges (select Excerpt), or save the entire table with a column " + "that marks failed relinking.
Press Cancel to stop." + ), + QPushButton(_("Save")), + QPushButton(_("Cancel")), default=2, ) critical_message.save_options() critical_message.dataframe(df.loc[critical["index"], :], SUPERSTRUCTURE) critical_message.dataframe_to_file(df_, critical["index"]) - response = critical_message.exec_() + critical_message.exec_() else: - msg = ( - f'An activity could not be "relinked" to the local database.
Some additional information is ' - f"provided. If you want to save the dataframe you can either save those scenario exchanges where " - f"relinking failed (check the excerpt box), or save the entire dataframe with a new column indicating" - f" failed relinking.
To abort the process press 'Cancel'" - ) critical_message = ABPopup.abCritical( - "Activity not found", - msg, - QPushButton("Save"), - QPushButton("Cancel"), + _("Activity not found"), + _( + "An activity could not be relinked to the local database.
" + "You can save only the failed scenario exchange (select Excerpt), " + "or save the entire table with a column that marks failed relinking." + "
Press Cancel to stop." + ), + QPushButton(_("Save")), + QPushButton(_("Cancel")), default=2, ) critical_message.save_options() critical_message.dataframe(df.loc[critical["index"], :], SUPERSTRUCTURE) critical_message.dataframe_to_file(df_, critical["index"]) - response = critical_message.exec_() + critical_message.exec_() QApplication.setOverrideCursor(Qt.WaitCursor) raise ScenarioDatabaseNotFoundError( "Incompatible Databases in the scenario file, unable to complete further checks on the file" diff --git a/activity_browser/bwutils/superstructure/file_dialogs.py b/activity_browser/bwutils/superstructure/file_dialogs.py index 57e4f3897..75089053f 100644 --- a/activity_browser/bwutils/superstructure/file_dialogs.py +++ b/activity_browser/bwutils/superstructure/file_dialogs.py @@ -2,6 +2,7 @@ from PySide2 import QtCore, QtWidgets from ...ui.icons import qicons +from ...i18n import _ """ The basic premise of this module is to contain a series of different popup menus that will allow the user @@ -54,7 +55,7 @@ def sync(self, *args, **kwargs) -> None: def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: - return self.columns[section] + return str(self.columns[section]) class ProblemDataFrame(QtWidgets.QTableView): @@ -146,11 +147,13 @@ def save_options(self): """ self.check_box.setVisible(True) self.check_box.setTristate(False) - self.check_box.setText("Excerpt") + self.check_box.setText(_("Excerpt")) self.check_box.setToolTip( - "If left unchecked the entire file is written with an additional column indicating " - "the status of the exchange data in the scenario file.
Check to save a smaller " - "excerpt of the file, containing only those exchanges that failed." + _( + "If left unchecked, the entire file is saved with an additional column " + "indicating the status of exchange data in the scenario file.
Check " + "this option to save a smaller excerpt containing only failed exchanges." + ) ) self.check_box.setChecked(False) self.updateGeometry() @@ -182,10 +185,10 @@ def save_dataframe(self): # Else we're not actually intending on saving anything else: return True - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + filepath, _selected_filter = QtWidgets.QFileDialog.getSaveFileName( parent=self, - caption="Choose the location to save the dataframe", - filter="Excel (*.xlsx *.xls);; CSV (*.csv)", + caption=_("Choose where to save the data table"), + filter=_("Excel (*.xlsx *.xls);;CSV (*.csv)"), ) QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) if filepath.endswith(".xlsx") or filepath.endswith(".xls"): @@ -229,14 +232,16 @@ def abQuestion(title, message, button1, button2): """ obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() - obj.setWindowTitle(title) - obj.label = QtWidgets.QLabel(message) + obj.setWindowTitle(_(title)) + obj.label = QtWidgets.QLabel(_(message)) obj.label.setWordWrap(True) obj.layout.addWidget(obj.label) obj.setWindowIcon(qicons.question) # add the interactive elements AND table obj.button1 = button1 obj.button2 = button2 + obj.button1.setText(_(obj.button1.text())) + obj.button2.setText(_(obj.button2.text())) obj.buttons.addWidget(obj.button1) obj.buttons.addWidget(obj.button2) obj.button_layout.addLayout(obj.buttons) @@ -272,8 +277,8 @@ def abWarning(title, message, button1, button2=None, default=1): """ obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() - obj.setWindowTitle(title) - obj.label = QtWidgets.QLabel(message) + obj.setWindowTitle(_(title)) + obj.label = QtWidgets.QLabel(_(message)) obj.label.setWordWrap(True) obj.layout.addWidget(obj.label) obj.setWindowIcon(qicons.warning) @@ -281,6 +286,9 @@ def abWarning(title, message, button1, button2=None, default=1): # add the interactive elements obj.button1 = button1 obj.button2 = button2 + obj.button1.setText(_(obj.button1.text())) + if obj.button2: + obj.button2.setText(_(obj.button2.text())) if button2: obj.buttons.addWidget(obj.button1) obj.buttons.addWidget(obj.button2) @@ -322,8 +330,8 @@ def abCritical(title, message, button1, button2=None, default=1): """ obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() - obj.setWindowTitle(title) - obj.label = QtWidgets.QLabel(message) + obj.setWindowTitle(_(title)) + obj.label = QtWidgets.QLabel(_(message)) obj.label.setWordWrap(True) obj.layout.addWidget(obj.label) obj.setWindowIcon(qicons.critical) @@ -331,6 +339,9 @@ def abCritical(title, message, button1, button2=None, default=1): # add the interactive elements obj.button1 = button1 obj.button2 = button2 + obj.button1.setText(_(obj.button1.text())) + if obj.button2: + obj.button2.setText(_(obj.button2.text())) if button2: obj.buttons.addWidget(button1) obj.buttons.addWidget(button2) diff --git a/activity_browser/bwutils/superstructure/manager.py b/activity_browser/bwutils/superstructure/manager.py index dd9cd70e3..bc95f1a49 100644 --- a/activity_browser/bwutils/superstructure/manager.py +++ b/activity_browser/bwutils/superstructure/manager.py @@ -10,6 +10,7 @@ from PySide2.QtWidgets import QApplication, QPushButton from activity_browser.mod import bw2data as bd +from activity_browser.i18n import _ from ..errors import (CriticalScenarioExtensionError, ImportCanceledError, ScenarioExchangeDataNonNumericError, @@ -125,21 +126,28 @@ def _combine_columns_intersect(self) -> pd.Index: ) ) if cols.empty: - msg = "While attempting to combine the scenario files an error was detected. No scenario columns were found in common between the files. For combining scenarios by extension at least one scenario needs to be found in common." + msg = _( + "The scenario files could not be combined because they have no " + "scenario columns in common. Extending scenarios requires at least " + "one scenario shared by all files." + ) critical = ABPopup.abCritical( - "Combining scenario files.", msg, QPushButton("Cancel") + _("Combining scenario files"), msg, QPushButton(_("Cancel")) ) critical.exec_() raise CriticalScenarioExtensionError elif len(absent) > 0: - msg = ( + msg = _( "

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 ``/*.json``. + single_file = root.joinpath(f"{language}.json") + if single_file.is_file(): + files.append(single_file) + + language_dir = root.joinpath(language) + if language_dir.is_dir(): + files.extend( + sorted( + ( + entry + for entry in language_dir.iterdir() + if entry.is_file() and entry.name.endswith(".json") + ), + key=lambda entry: entry.name, + ) + ) + return files + + def load_catalog(self, language: str) -> Dict[str, str]: + """Load and safely merge all JSON fragments for ``language``.""" + + resolved = resolve_language(language, self._system_locale) + catalog: Dict[str, str] = {} + origins = {} + + for catalog_file in self._catalog_files(resolved): + try: + content = json.loads(catalog_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise TranslationCatalogError( + f"Could not read translation catalog {catalog_file}: {exc}" + ) from exc + + if not isinstance(content, dict): + raise TranslationCatalogError( + f"Translation catalog {catalog_file} must contain a JSON object" + ) + + for source, translation in content.items(): + if not isinstance(source, str) or not isinstance(translation, str): + raise TranslationCatalogError( + f"Translation catalog {catalog_file} must map strings to strings" + ) + if source in catalog and catalog[source] != translation: + raise TranslationCatalogError( + f"Conflicting translation for {source!r} in " + f"{origins[source]} and {catalog_file}" + ) + catalog[source] = translation + origins[source] = catalog_file + + return catalog + + def _remove_installed_translators(self) -> None: + if self._application is None: + return + if self._catalog_translator is not None: + self._application.removeTranslator(self._catalog_translator) + if self._qt_translator is not None: + self._application.removeTranslator(self._qt_translator) + self._catalog_translator = None + self._qt_translator = None + + def install(self, application, language: Optional[str] = SYSTEM_LANGUAGE) -> str: + """Install AB and Qt translations, returning the active language code.""" + + requested = normalize_language(language) + resolved = resolve_language(requested, self._system_locale) + catalog = self.load_catalog(resolved) + + self._remove_installed_translators() + self._application = application + self._requested_language = requested + self._current_language = resolved + self._catalog = catalog + + if resolved == SIMPLIFIED_CHINESE: + qt_translator = QTranslator() + translations_path = QLibraryInfo.location(QLibraryInfo.TranslationsPath) + for catalog_name in ("qt_zh_CN", "qtbase_zh_CN"): + if qt_translator.load(catalog_name, translations_path): + application.installTranslator(qt_translator) + self._qt_translator = qt_translator + break + + catalog_translator = CatalogTranslator(catalog) + application.installTranslator(catalog_translator) + self._catalog_translator = catalog_translator + log.info( + "Loaded Activity Browser interface language %s (setting: %s)", + resolved, + requested, + ) + return resolved + + def gettext(self, source: str, **kwargs) -> str: + """Translate a UI source string and optionally format named fields.""" + + if not isinstance(source, str): + raise TypeError("Translation source must be a string") + translated = self._catalog.get(source, source) + return translated.format(**kwargs) if kwargs else translated + + +translation_manager = TranslationManager() + + +def _(source: str, **kwargs) -> str: + """Translate an Activity Browser interface string. + + Named ``str.format`` placeholders are applied after translation, allowing a + language to reorder values without callers assembling translated fragments. + """ + + return translation_manager.gettext(source, **kwargs) + + +def current_language() -> str: + """Return the active concrete language code (``en_US`` or ``zh_CN``).""" + + return translation_manager.current_language + + +def language_choices() -> Tuple[Tuple[str, str], ...]: + """Return ``(stable code, translated display name)`` language choices.""" + + return tuple((code, _(_LANGUAGE_LABELS[code])) for code in SUPPORTED_LANGUAGES) + + +__all__ = [ + "_", + "CatalogTranslator", + "ENGLISH", + "SIMPLIFIED_CHINESE", + "SUPPORTED_LANGUAGES", + "SYSTEM_LANGUAGE", + "TranslationCatalogError", + "TranslationManager", + "current_language", + "language_choices", + "normalize_language", + "resolve_language", + "translation_manager", +] diff --git a/activity_browser/layouts/main.py b/activity_browser/layouts/main.py index 3fb6e80c8..e83552403 100644 --- a/activity_browser/layouts/main.py +++ b/activity_browser/layouts/main.py @@ -4,6 +4,7 @@ import activity_browser.mod.bw2data as bd from ..signals import signals +from ..i18n import _ from ..ui.icons import qicons from ..ui.menu_bar import MenuBar from ..ui.statusbar import Statusbar @@ -12,7 +13,7 @@ class MainWindow(QtWidgets.QMainWindow): - DEFAULT_NO_METHOD = "No method selected yet" + DEFAULT_NO_METHOD = _("No method selected yet") def __init__(self, parent): super(MainWindow, self).__init__(None) @@ -55,7 +56,7 @@ def __init__(self, parent): self.main_window = QtWidgets.QWidget() self.main_window.setLayout(self.main_horizontal_box) self.main_window.icon = qicons.main_window - self.main_window.name = "&Main Window" + self.main_window.name = _("&Main Window") self.setCentralWidget(self.main_window) @@ -95,7 +96,7 @@ def dialog(self, title, label): def info(self, label): QtWidgets.QMessageBox.information( self, - "Information", + _("Information"), label, QtWidgets.QMessageBox.Ok, ) @@ -106,7 +107,7 @@ def warning(self, title, text): def confirm(self, label): response = QtWidgets.QMessageBox.question( self, - "Confirm Action", + _("Confirm action"), label, QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No, diff --git a/activity_browser/layouts/panels/left.py b/activity_browser/layouts/panels/left.py index c352f7729..bda607ccd 100644 --- a/activity_browser/layouts/panels/left.py +++ b/activity_browser/layouts/panels/left.py @@ -1,22 +1,22 @@ # -*- coding: utf-8 -*- from ..tabs import HistoryTab, MethodsTab, ProjectTab -from .panel import ABTab +from activity_browser.i18n import _ + +from .panel import ABTab, TabId class LeftPanel(ABTab): side = "left" def __init__(self, *args): - from ..tabs import HistoryTab, MethodsTab, ProjectTab - super(LeftPanel, self).__init__(*args) - self.tabs = { - "Project": ProjectTab(self), - "Impact Categories": MethodsTab(self), - "History": HistoryTab(self), - } - for tab_name, tab in self.tabs.items(): - self.addTab(tab, tab_name) + tabs = ( + (TabId.PROJECT, "Project", ProjectTab(self)), + (TabId.IMPACT_CATEGORIES, "Impact Categories", MethodsTab(self)), + (TabId.HISTORY, "History", HistoryTab(self)), + ) + for tab_id, source_label, tab in tabs: + self.add_tab(tab, tab_id, _(source_label), aliases=(source_label,)) # tabs hidden at start - self.hide_tab("History") + self.hide_tab(TabId.HISTORY) diff --git a/activity_browser/layouts/panels/panel.py b/activity_browser/layouts/panels/panel.py index a6b87d01f..c4d5314e0 100644 --- a/activity_browser/layouts/panels/panel.py +++ b/activity_browser/layouts/panels/panel.py @@ -7,11 +7,36 @@ log = getLogger(__name__) +class TabId: + """Stable identifiers for the built-in top-level tabs. + + These values are sent through the global tab signals and must therefore + never be translated. User-visible tab labels are stored separately by + :class:`ABTab`. + """ + + PROJECT = "left.project" + IMPACT_CATEGORIES = "left.impact_categories" + HISTORY = "left.history" + + WELCOME = "right.welcome" + CHARACTERIZATION_FACTORS = "right.characterization_factors" + ACTIVITY_DETAILS = "right.activity_details" + LCA_SETUP = "right.lca_setup" + GRAPH_EXPLORER = "right.graph_explorer" + LCA_RESULTS = "right.lca_results" + PARAMETERS = "right.parameters" + + class ABTab(QtWidgets.QTabWidget): def __init__(self, parent=None): super(ABTab, self).__init__(parent) self.setMovable(True) - self.tabs = dict() # keys: tab name; values: tab widget + # ``tabs`` is keyed by stable identifiers. Display labels live in a + # separate mapping so translating a label can never break tab logic. + self.tabs = dict() + self.tab_labels = dict() + self.tab_aliases = dict() # signals signals.show_tab.connect(self.show_tab) @@ -26,30 +51,51 @@ def current_index_changed(self, current_index: int): """Optional function to accept the index of the selected tab.""" pass # NotImplementedError is not used as this function gets called often and not neccecarily used. - def add_tab(self, obj, tab_name): - """Default addTab method and add item to self.tabs""" - self.tabs[tab_name] = obj - self.addTab(obj, tab_name) + def add_tab(self, obj, tab_id, tab_label=None, aliases=()): + """Add a tab using a stable ID and an independently translated label. + + ``aliases`` keeps compatibility with older signal emitters which used + the English label as the tab identifier. + """ + label = tab_label if tab_label is not None else str(tab_id) + self.tabs[tab_id] = obj + self.tab_labels[tab_id] = label + self.tab_aliases[tab_id] = tab_id + self.tab_aliases[label] = tab_id + for alias in aliases: + self.tab_aliases[alias] = tab_id + return self.addTab(obj, label) + + def _resolve_tab_id(self, tab_id): + """Resolve a stable tab ID, translated label, or legacy English label.""" + try: + if tab_id in self.tabs: + return tab_id + return self.tab_aliases.get(tab_id) + except TypeError: + return None def select_tab(self, obj): """Brings tab to focus.""" self.setCurrentIndex(self.indexOf(obj)) - def toggle_tab_visibility(self, tab_name): + def toggle_tab_visibility(self, tab_id): """Show or hide a tab. Used, e.g. for Windows-->show/hide menu.""" - if tab_name in self.tabs: - if self.indexOf(self.tabs[tab_name]) != -1: - self.hide_tab(tab_name) + tab_id = self._resolve_tab_id(tab_id) + if tab_id is not None: + if self.indexOf(self.tabs[tab_id]) != -1: + self.hide_tab(tab_id) else: - self.show_tab(tab_name) + self.show_tab(tab_id) - def hide_tab(self, tab_name, current_index=0): + def hide_tab(self, tab_id, current_index=0): """Hides tab, but does not delete the QTabWidget itself.""" - if tab_name in self.tabs: - tab = self.tabs[tab_name] + tab_id = self._resolve_tab_id(tab_id) + if tab_id is not None: + tab = self.tabs[tab_id] if self.indexOf(tab) != -1: - log.debug("Hiding tab: ", tab_name) + log.debug("Hiding tab: %s", tab_id) tab.setVisible(False) # Only explicitly alter the tab index if we're hiding the # current tab itself. @@ -57,13 +103,14 @@ def hide_tab(self, tab_name, current_index=0): self.setCurrentIndex(current_index) self.removeTab(self.indexOf(tab)) - def show_tab(self, tab_name): + def show_tab(self, tab_id): """Makes existing tab visible.""" - if tab_name in self.tabs: - tab = self.tabs[tab_name] - log.info(f"+showing tab: {tab_name}") + tab_id = self._resolve_tab_id(tab_id) + if tab_id is not None: + tab = self.tabs[tab_id] + log.info("+showing tab: %s", tab_id) tab.setVisible(True) - self.addTab(tab, tab_name) + self.addTab(tab, self.tab_labels.get(tab_id, str(tab_id))) self.select_tab(tab) def get_tab_name(self, obj): @@ -75,36 +122,42 @@ def get_tab_name(self, obj): log.warning(f"found {len(tab_names)} occurrences of this object.") def get_tab_name_from_index(self, index): - """Return the name of a tab based on its index.""" - tab_names = [self.tabText(i) for i in range(self.count()) if i == index] - if len(tab_names) == 1: - return tab_names[0] - else: - log.warning("Did not find instance of tab") + """Return the stable ID of a tab based on its visible index.""" + widget = self.widget(index) + if widget is not None: + return self.get_tab_name(widget) + log.warning("Did not find instance of tab") def hide_when_empty(self): """Show tab if it has sub-tabs (not empty) or hide if it has no sub-tabs (empty).""" - for tab_name, tab in self.tabs.items(): + for tab_id, tab in self.tabs.items(): if hasattr(tab, "tabs"): if not tab.tabs: - self.hide_tab(tab_name) + self.hide_tab(tab_id) # else: # leads to strange behaviour of setCurrentIndex/select_tab # self.show_tab(tab_name) def close_tab(self, index): """Close tab by index.""" widget = self.widget(index) - tab_name = self.get_tab_name(widget) + tab_id = self.get_tab_name(widget) if widget in self.tabs.values(): - del self.tabs[tab_name] + del self.tabs[tab_id] + self.tab_labels.pop(tab_id, None) + self.tab_aliases = { + alias: target + for alias, target in self.tab_aliases.items() + if target != tab_id + } widget.deleteLater() self.removeTab(index) signals.hide_when_empty.emit() # needs to be a signal as we want the super-tab to receive this... - def close_tab_by_tab_name(self, tab_name): - """Close tab by tab name (key in self.tabs).""" - if tab_name in self.tabs: - self.close_tab(self.indexOf(self.tabs[tab_name])) + def close_tab_by_tab_name(self, tab_id): + """Close a tab by stable ID (legacy labels are also accepted).""" + tab_id = self._resolve_tab_id(tab_id) + if tab_id is not None: + self.close_tab(self.indexOf(self.tabs[tab_id])) def close_all(self): """Close all tabs.""" diff --git a/activity_browser/layouts/panels/right.py b/activity_browser/layouts/panels/right.py index 39dceebb2..3a7e3f224 100644 --- a/activity_browser/layouts/panels/right.py +++ b/activity_browser/layouts/panels/right.py @@ -4,13 +4,14 @@ from PySide2.QtWidgets import QVBoxLayout from activity_browser import signals +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from ...bwutils.commontasks import get_activity_name from ...ui.web import GraphNavigatorWidget, RestrictedWebViewWidget from ..tabs import (ActivitiesTab, CharacterizationFactorsTab, LCAResultsTab, LCASetupTab, ParametersTab) -from .panel import ABTab +from .panel import ABTab, TabId log = getLogger(__name__) @@ -22,39 +23,48 @@ def __init__(self, *args): super(RightPanel, self).__init__(*args) package_dir = Path(__file__).resolve().parents[2] html_file = str(package_dir.joinpath("static", "startscreen", "welcome.html")) - self.tabs = { - "Welcome": RestrictedWebViewWidget(html_file=html_file), - "Characterization Factors": CharacterizationFactorsTab(self), - "Activity Details": ActivitiesTab(self), - "LCA Setup": LCASetupTab(self), - "Graph Explorer": GraphExplorerTab(self), - "LCA results": LCAResultsTab(self), - "Parameters": ParametersTab(self), - } self.tab_order = {} - - for tab_name, tab in self.tabs.items(): - self.tab_order[tab_name] = self.addTab(tab, tab_name) + tabs = ( + (TabId.WELCOME, "Welcome", RestrictedWebViewWidget(html_file=html_file)), + ( + TabId.CHARACTERIZATION_FACTORS, + "Characterization Factors", + CharacterizationFactorsTab(self), + ), + (TabId.ACTIVITY_DETAILS, "Activity Details", ActivitiesTab(self)), + (TabId.LCA_SETUP, "LCA Setup", LCASetupTab(self)), + (TabId.GRAPH_EXPLORER, "Graph Explorer", GraphExplorerTab(self)), + (TabId.LCA_RESULTS, "LCA results", LCAResultsTab(self)), + (TabId.PARAMETERS, "Parameters", ParametersTab(self)), + ) + + for tab_id, source_label, tab in tabs: + self.tab_order[tab_id] = self.add_tab( + tab, tab_id, _(source_label), aliases=(source_label,) + ) # tabs hidden at start - for tab_name in [ - "Activity Details", - "Characterization Factors", - "Graph Explorer", - "LCA results", + for tab_id in [ + TabId.ACTIVITY_DETAILS, + TabId.CHARACTERIZATION_FACTORS, + TabId.GRAPH_EXPLORER, + TabId.LCA_RESULTS, ]: - self.hide_tab(tab_name) + self.hide_tab(tab_id) - def show_tab(self, tab_name): + def show_tab(self, tab_id): """Re-inserts tab at the initial location. This avoids constantly re-ordering the mayor tabs. """ - if tab_name in self.tabs: - tab = self.tabs[tab_name] - log.info(f"+showing tab: {tab_name}") + tab_id = self._resolve_tab_id(tab_id) + if tab_id is not None: + tab = self.tabs[tab_id] + log.info("+showing tab: %s", tab_id) tab.setVisible(True) - self.insertTab(self.tab_order[tab_name], tab, tab_name) + self.insertTab( + self.tab_order[tab_id], tab, self.tab_labels.get(tab_id, str(tab_id)) + ) self.select_tab(tab) @@ -85,7 +95,9 @@ def add_tab(self, key, select=True): self.tabs[key] = new_tab self.addTab(new_tab, new_tab.objectName()) - new_tab.objectNameChanged.connect(lambda name: self.setTabText(self.indexOf(new_tab), name)) + new_tab.objectNameChanged.connect( + lambda name: self.setTabText(self.indexOf(new_tab), name) + ) else: tab = self.tabs[key] @@ -93,4 +105,4 @@ def add_tab(self, key, select=True): if select: self.select_tab(self.tabs[key]) - signals.show_tab.emit("Graph Explorer") + signals.show_tab.emit(TabId.GRAPH_EXPLORER) diff --git a/activity_browser/layouts/tabs/LCA_results_tab.py b/activity_browser/layouts/tabs/LCA_results_tab.py index 1ef6b84d9..63475395f 100644 --- a/activity_browser/layouts/tabs/LCA_results_tab.py +++ b/activity_browser/layouts/tabs/LCA_results_tab.py @@ -7,6 +7,7 @@ from PySide2.QtWidgets import QApplication, QMessageBox, QVBoxLayout from activity_browser import signals +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from ...bwutils.errors import ABError @@ -16,6 +17,13 @@ log = getLogger(__name__) +def calculation_tab_label(name: str, calculation_type: str) -> str: + """Return a localized label without changing the calculation setup name.""" + if calculation_type == "scenario": + return _("{name}[Scenarios]", name=name) + return name + + class LCAResultsTab(ABTab): """Tab that contains subtabs for each calculation setup.""" @@ -51,21 +59,22 @@ def generate_setup(self, data: dict): calculation_type = data.get("calculation_type", "simple") if calculation_type == "scenario": - name = "{}[Scenarios]".format(cs_name) + internal_name = "{}[Scenarios]".format(cs_name) else: - name = cs_name - self.remove_setup(name) + internal_name = cs_name + display_name = calculation_tab_label(cs_name, calculation_type) + self.remove_setup(internal_name) try: new_tab = LCAResultsSubTab(data, self) - self.tabs[name] = new_tab - self.addTab(new_tab, name) - self.select_tab(self.tabs[name]) + self.tabs[internal_name] = new_tab + self.addTab(new_tab, display_name) + self.select_tab(self.tabs[internal_name]) new_tab.destroyed.connect( lambda: ( - self.tabs.pop(name) - if id(self.tabs.get(name, None)) == id(new_tab) + self.tabs.pop(internal_name) + if id(self.tabs.get(internal_name, None)) == id(new_tab) else None ) ) @@ -78,7 +87,7 @@ def generate_setup(self, data: dict): QApplication.restoreOverrideCursor() msg = QMessageBox( QMessageBox.Warning, - "Calculation problem", + _("Calculation problem"), str(initial), QMessageBox.Ok, self, diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index 57c76be7c..490c1a97a 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -26,6 +26,7 @@ import bw2data as bd from activity_browser import signals, project_settings +from activity_browser.i18n import _ from activity_browser.mod.bw2data import calculation_setups from activity_browser.mod.bw2analyzer import ABContributionAnalysis @@ -46,6 +47,20 @@ log = getLogger(__name__) +class InventoryType: + BIOSPHERE = "biosphere" + TECHNOSPHERE = "technosphere" + + +class CategorisationFilter: + ALL = "all" + WITHOUT_FACTORS = "without_factors" + WITH_FACTORS = "with_factors" + + +NO_AGGREGATION = "none" + + def get_header_layout(header_text: str) -> QVBoxLayout: vlayout = QVBoxLayout() vlayout.addWidget(header(header_text)) @@ -74,6 +89,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: - absolute numbers. """ if relative: + # Keep dataframe and export values stable; translate only on display. return "relative share" if method: # for all reference flows return bc.unit_of_method(method) @@ -150,14 +166,14 @@ def __init__(self, data: dict, parent=None): gsa=GSATab(self), ) self.tab_names = Tabs( - inventory="Inventory", - results="LCA Results", - ef="EF Contributions", - process="Process Contributions", - ft="FT Contributions", - sankey="Sankey", - mc="Monte Carlo", - gsa="Sensitivity Analysis", + inventory=_("Inventory"), + results=_("LCA Results"), + ef=_("EF Contributions"), + process=_("Process Contributions"), + ft=_("FT Contributions"), + sankey=_("Sankey"), + mc=_("Monte Carlo"), + gsa=_("Sensitivity Analysis"), ) self.setup_tabs() self.setCurrentWidget(self.tabs.results) @@ -207,10 +223,10 @@ def generate_lcia_scenario_csv(self): impact categories and scenarios, then call the 'export to csv' """ df = self.mlca.lca_scores_to_dataframe() - filepath, _ = QFileDialog.getSaveFileName( + filepath, _selected_filter = QFileDialog.getSaveFileName( parent=self, - caption="Choose location to save lca results", - filter="Comma Separated Values (*.csv);; All Files (*.*)", + caption=_("Choose location to save LCA results"), + filter=_("Comma Separated Values (*.csv);; All Files (*.*)"), ) if filepath: if not filepath.endswith(".csv"): @@ -223,10 +239,10 @@ def generate_lcia_scenario_excel(self): impact categories and scenarios, then call the 'export to excel' """ df = self.mlca.lca_scores_to_dataframe() - filepath, _ = QFileDialog.getSaveFileName( + filepath, _selected_filter = QFileDialog.getSaveFileName( parent=self, - caption="Choose location to save lca results", - filter="Excel (*.xlsx);; All Files (*.*)", + caption=_("Choose location to save LCA results"), + filter=_("Excel (*.xlsx);; All Files (*.*)"), ) if filepath: if not filepath.endswith(".xlsx"): @@ -272,10 +288,14 @@ def build_main_space(self, invertable: bool = False) -> QScrollArea: space.setWidgetResizable(True) # Option switches - self.plot_table = PlotTableCheck(QCheckBox("Plot"), QCheckBox("Table"), None) + self.plot_table = PlotTableCheck( + QCheckBox(_("Plot")), QCheckBox(_("Table")), None + ) if invertable: self.plot_table = PlotTableCheck( - QCheckBox("Plot"), QCheckBox("Table"), QCheckBox("Invert") + QCheckBox(_("Plot")), + QCheckBox(_("Table")), + QCheckBox(_("Invert")), ) self.plot_table.invert.setChecked(False) self.plot_table.invert.stateChanged.connect(self.invert_plot) @@ -422,7 +442,7 @@ def build_export( if has_plot: plot_layout = QHBoxLayout() self.export_plot = ExportPlot( - QLabel("Export plot:"), + QLabel(_("Export plot:")), QPushButton(".png"), QPushButton(".svg"), ) @@ -440,10 +460,10 @@ def build_export( if has_table: table_layout = QHBoxLayout() self.export_table = ExportTable( - QLabel("Export table:"), - QPushButton("Copy"), + QLabel(_("Export table:")), + QPushButton(_("Copy")), QPushButton(".csv"), - QPushButton("Excel"), + QPushButton(_("Excel")), ) self.export_table.copy.clicked.connect(self.table.to_clipboard) self.export_table.csv.clicked.connect(self.table.to_csv) @@ -472,22 +492,37 @@ def __init__(self, parent=None): self.df_biosphere = None self.df_technosphere = None - self.layout.addLayout(get_header_layout("Inventory")) + self.layout.addLayout(get_header_layout(_("Inventory"))) self.bio_tech_button_group = QButtonGroup() self.bio_categorisation_factor_group = QComboBox() # buttons button_layout = QHBoxLayout() - self.radio_button_biosphere = QRadioButton("Biosphere flows") + self.radio_button_biosphere = QRadioButton(_("Biosphere flows")) + self.radio_button_biosphere.setProperty( + "inventory_type", InventoryType.BIOSPHERE + ) self.radio_button_biosphere.setChecked(True) - self.radio_button_technosphere = QRadioButton("Technosphere flows") - self.remove_zeros_checkbox = QCheckBox("Remove '0' values") + self.radio_button_technosphere = QRadioButton(_("Technosphere flows")) + self.radio_button_technosphere.setProperty( + "inventory_type", InventoryType.TECHNOSPHERE + ) + self.remove_zeros_checkbox = QCheckBox(_("Remove '0' values")) self.remove_zero_state = False self.categorisation_factor_filters = [ - "No filtering with categorisation factors", - "Flows without categorisation factors", - "Flows with categorisation factors", + ( + CategorisationFilter.ALL, + _("No filtering with categorisation factors"), + ), + ( + CategorisationFilter.WITHOUT_FACTORS, + _("Flows without categorisation factors"), + ), + ( + CategorisationFilter.WITH_FACTORS, + _("Flows with categorisation factors"), + ), ] self.categorisation_factor_state = None self.old_categorisation_factor_state = self.categorisation_factor_state @@ -495,16 +530,19 @@ def __init__(self, parent=None): self.last_remove_zero_state = self.remove_zero_state self.remove_zeros_checkbox.setChecked(self.remove_zero_state) self.remove_zeros_checkbox.setToolTip( - "Choose whether to show '0' values or not.\n" - "When selected, '0' values are not shown.\n" - "Rows are only removed when all reference flows are '0'." + _( + "Choose whether to show '0' values or not.\n" + "When selected, '0' values are not shown.\n" + "Rows are only removed when all reference flows are '0'." + ) ) - self.scenario_label = QLabel("Scenario:") + self.scenario_label = QLabel(_("Scenario:")) # Group the radio buttons into the appropriate groups for the window - self.update_combobox( - self.bio_categorisation_factor_group, self.categorisation_factor_filters - ) + for filter_id, label in self.categorisation_factor_filters: + self.bio_categorisation_factor_group.addItem(label, filter_id) + self.bio_tech_button_group.addButton(self.radio_button_biosphere) + self.bio_tech_button_group.addButton(self.radio_button_technosphere) self.bio_categorisation_factor_group.setMaximumWidth(300) self.bio_categorisation_factor_group.setSizeAdjustPolicy( QComboBox.AdjustToContentsOnFirstShow @@ -512,7 +550,7 @@ def __init__(self, parent=None): # Setup the Qt environment for the buttons, including the arrangement self.categorisation_filter_layout = QVBoxLayout() - self.categorisation_filter_layout.addWidget(QLabel("Filter flows:")) + self.categorisation_filter_layout.addWidget(QLabel(_("Filter flows:"))) self.categorisation_filter_layout.addWidget( self.bio_categorisation_factor_group ) @@ -531,7 +569,7 @@ def __init__(self, parent=None): self.layout.addWidget(self.categorisation_filter_box) # table self.table = InventoryTable(self.parent) - self.table.table_name = "Inventory_" + self.parent.cs_name + self.table.table_name = _("Inventory") + "_" + self.parent.cs_name self.layout.addWidget(self.table) self.layout.addLayout(self.build_export(has_plot=False, has_table=True)) @@ -556,16 +594,11 @@ def connect_signals(self): @QtCore.Slot(QRadioButton, name="addCategorisationFactorFilter") def add_categorisation_factor_filter(self, index: int): - if ( - self.bio_categorisation_factor_group.currentText() - == "Flows without categorisation factors" - ): + filter_id = self.bio_categorisation_factor_group.currentData() + if filter_id == CategorisationFilter.WITHOUT_FACTORS: self.categorisation_filter_with_flows = False self.categorisation_factor_state = False - elif ( - self.bio_categorisation_factor_group.currentText() - == "Flows with categorisation factors" - ): + elif filter_id == CategorisationFilter.WITH_FACTORS: self.categorisation_filter_with_flows = True self.categorisation_factor_state = True else: @@ -576,7 +609,7 @@ def add_categorisation_factor_filter(self, index: int): @QtCore.Slot(QRadioButton, name="toggleCategorisationFactorFilterButtons") def toggle_categorisation_factor_filter_buttons(self, bttn: QRadioButton): - if bttn.text() == "Biosphere flows": + if bttn.property("inventory_type") == InventoryType.BIOSPHERE: self.categorisation_filter_box.setVisible(True) else: self.categorisation_filter_box.setVisible(False) @@ -592,8 +625,8 @@ def remove_zeros_checked(self, toggled: bool): @QtCore.Slot(bool, name="isBiosphereToggled") def button_clicked(self, toggled: bool): """Update table according to radiobutton selected.""" - ext = "_Inventory" if toggled else "_Inventory_technosphere" - self.table.table_name = "{}{}".format(self.parent.cs_name, ext) + result_type = _("Inventory") if toggled else _("Technosphere inventory") + self.table.table_name = "{}_{}".format(self.parent.cs_name, result_type) self.update_table() def configure_scenario(self): @@ -632,7 +665,9 @@ def elementary_flows_contributing_to_IA_methods( def update_table(self): """Update the table.""" inventory = ( - "biosphere" if self.radio_button_biosphere.isChecked() else "technosphere" + InventoryType.BIOSPHERE + if self.radio_button_biosphere.isChecked() + else InventoryType.TECHNOSPHERE ) self.table.showing = inventory # We handle both 'df_biosphere' and 'df_technosphere' variables here. @@ -651,7 +686,7 @@ def update_table(self): # filter the biosphere flows for the relevance to the CFs if ( self.categorisation_filter_with_flows is not None - and inventory == "biosphere" + and inventory == InventoryType.BIOSPHERE ): self.df_biosphere = self.elementary_flows_contributing_to_IA_methods( self.categorisation_filter_with_flows, self.df_biosphere @@ -696,22 +731,24 @@ def __init__(self, parent=None): self.lca_overview_widget = LCIAResultsTab(parent) self.layout.setAlignment(QtCore.Qt.AlignTop) - self.layout.addLayout(get_header_layout("LCA Results")) + self.layout.addLayout(get_header_layout(_("LCA Results"))) # buttons button_layout = QHBoxLayout() self.button_group = QButtonGroup() - self.button_overview = QRadioButton("Overview") + self.button_overview = QRadioButton(_("Overview")) self.button_overview.setToolTip( - "Show a matrix of all reference flows and all impact categories" + _("Show a matrix of all reference flows and all impact categories") ) button_layout.addWidget(self.button_overview) - self.button_by_method = QRadioButton("by impact category") + self.button_by_method = QRadioButton(_("by impact category")) self.button_by_method.setToolTip( - "Show the impacts of each reference flow for the selected impact categories" + _( + "Show the impacts of each reference flow for the selected impact categories" + ) ) self.button_by_method.setChecked(True) - self.scenario_label = QLabel("Scenario:") + self.scenario_label = QLabel(_("Scenario:")) self.button_group.addButton(self.button_overview, 0) self.button_group.addButton(self.button_by_method, 1) button_layout.addWidget(self.button_by_method) @@ -767,7 +804,7 @@ def __init__(self, parent=None): self.parent = parent self.combobox_menu = QHBoxLayout() - self.combobox_label = QLabel("Choose impact category:") + self.combobox_label = QLabel(_("Choose impact category:")) self.combobox = QComboBox() self.combobox.scroll = False self.combobox_menu.addWidget(self.combobox_label) @@ -776,7 +813,7 @@ def __init__(self, parent=None): self.layout.addLayout(self.combobox_menu) self.plot = LCAResultsBarChart(self.parent) - self.plot.plot_name = "LCA scores_" + self.parent.cs_name + self.plot.plot_name = _("LCA scores") + "_" + self.parent.cs_name self.layout.addWidget(self.plot) self.layout.addLayout(self.build_export(has_plot=True, has_table=False)) @@ -796,18 +833,18 @@ def build_export( stretch = layout.takeAt(layout.count() - 1) # Then add the additional label and export btn, plus new stretch. exp_layout = QHBoxLayout() - exp_layout.addWidget(QLabel("Export all data")) + exp_layout.addWidget(QLabel(_("Export all data"))) csv_btn = QPushButton(".csv") csv_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" + _("Include all reference flows, impact categories and scenarios") ) if self.parent: csv_btn.clicked.connect(self.parent.generate_lcia_scenario_csv) - excel_btn = QPushButton("Excel") + excel_btn = QPushButton(_("Excel")) excel_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" + _("Include all reference flows, impact categories and scenarios") ) if self.parent: excel_btn.clicked.connect(self.parent.generate_lcia_scenario_excel) @@ -840,7 +877,9 @@ def update_plot(self, method_index: int = 0): self.layout.insertWidget(idx, self.plot) super().update_plot(df, method=method, labels=labels) self.updateGeometry() - self.plot.plot_name = "_".join([self.parent.cs_name, "LCA scores", str(method)]) + self.plot.plot_name = "_".join( + [self.parent.cs_name, _("LCA scores"), str(method)] + ) class LCIAResultsTab(NewAnalysisTab): @@ -854,9 +893,9 @@ def __init__(self, parent, **kwargs): # if not self.parent.single_func_unit: self.plot = LCAResultsPlot(self.parent) - self.plot.plot_name = self.parent.cs_name + "_LCIA results" + self.plot.plot_name = self.parent.cs_name + "_" + _("LCIA results") self.table = LCAResultsTable(self.parent) - self.table.table_name = self.parent.cs_name + "_LCIA results" + self.table.table_name = self.parent.cs_name + "_" + _("LCIA results") self.relative = False self.layout.addWidget(self.build_main_space(True)) @@ -872,18 +911,18 @@ def build_export( stretch = layout.takeAt(layout.count() - 1) # Then add the additional label and export btn, plus new stretch. exp_layout = QHBoxLayout() - exp_layout.addWidget(QLabel("Export all data")) + exp_layout.addWidget(QLabel(_("Export all data"))) csv_btn = QPushButton(".csv") csv_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" + _("Include all reference flows, impact categories and scenarios") ) if self.parent: csv_btn.clicked.connect(self.parent.generate_lcia_scenario_csv) - excel_btn = QPushButton("Excel") + excel_btn = QPushButton(_("Excel")) excel_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" + _("Include all reference flows, impact categories and scenarios") ) if self.parent: excel_btn.clicked.connect(self.parent.generate_lcia_scenario_excel) @@ -922,56 +961,62 @@ def __init__(self, parent, **kwargs): self.cutoff_menu = CutoffMenu(self, cutoff_value=0.05) self.combobox_menu = Combobox( func=QComboBox(self), - func_label=QLabel("Reference Flow:"), + func_label=QLabel(_("Reference Flow:")), method=QComboBox(self), - method_label=QLabel("Impact Category:"), + method_label=QLabel(_("Impact Category:")), agg=QComboBox(self), - agg_label=QLabel("Aggregate by:"), + agg_label=QLabel(_("Aggregate by:")), scenario=self.scenario_box, - scenario_label=QLabel("Scenario:"), + scenario_label=QLabel(_("Scenario:")), ) - self.switch_label = QLabel("Compare:") + self.switch_label = QLabel(_("Compare:")) self.switches = SwitchComboBox(self) self.relativity = Relativity( - QRadioButton("Relative"), - QRadioButton("Absolute"), + QRadioButton(_("Relative")), + QRadioButton(_("Absolute")), ) self.relativity.relative.setChecked(True) self.relative = True self.relativity.relative.setToolTip( - "Show relative values (compare fraction of each contribution)" + _("Show relative values (compare fraction of each contribution)") ) self.relativity.absolute.setToolTip( - "Show absolute values (compare magnitudes of each contribution)" + _("Show absolute values (compare magnitudes of each contribution)") ) self.relativity_group = QButtonGroup(self) self.relativity_group.addButton(self.relativity.relative) self.relativity_group.addButton(self.relativity.absolute) self.total_menu = TotalMenu( - QRadioButton("Score"), - QRadioButton("Range"), + QRadioButton(_("Score")), + QRadioButton(_("Range")), ) self.total_menu.score.setChecked(True) self.total_range = False self.total_menu.score.setToolTip( - "Show the contributions relative to the total impact score.\n" - "e.g. total negative results is -2 and total positive results is 10, then score is 8 (-2 + 10)" + _( + "Show the contributions relative to the total impact score.\n" + "e.g. total negative results is -2 and total positive results is 10, then score is 8 (-2 + 10)" + ) ) self.total_menu.range.setToolTip( - "Show the contribution relative to the total range of results.\n" - "e.g. total negative results is -2 and total positive results is 10, then range is 12 (-2 * -1 + 10)" + _( + "Show the contribution relative to the total range of results.\n" + "e.g. total negative results is -2 and total positive results is 10, then range is 12 (-2 * -1 + 10)" + ) ) self.total_group = QButtonGroup(self) self.total_group.addButton(self.total_menu.score) self.total_group.addButton(self.total_menu.range) self.score_marker = project_settings.settings.get("analysis_tab", {}).get(f"{self.__class__.__name__}score_marker_enabled", True) - self.score_mrk_checkbox = QCheckBox("Score Marker") + self.score_mrk_checkbox = QCheckBox(_("Score Marker")) self.score_mrk_checkbox.setToolTip( - "Shows the score marker. When there are both positive and negative results,\n" - "this shows a marker where the total score is." + _( + "Shows the score marker. When there are both positive and negative results,\n" + "this shows a marker where the total score is." + ) ) self.score_mrk_checkbox.setChecked(self.score_marker) @@ -981,25 +1026,30 @@ def __init__(self, parent, **kwargs): self.contribution_fn = None self.has_method, self.has_func = False, False self.unit = None + self.translate_unit = False self.has_been_opened = False # set-up the help button - self.explain_text = """ -

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 = _( + "

There are three ways of doing Contribution Analysis in Activity Browser:

" + "

- 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 Analysis

" + "

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 (GSA)

" + "

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("", self) - self.load_btn = QtWidgets.QPushButton(qicons.import_db, "Load") - self.load_btn.setToolTip("Load (new) data for this scenario table") - self.remove_btn = QtWidgets.QPushButton(qicons.delete, "Delete") - self.remove_btn.setToolTip("Remove this scenario table") + self.scenario_name = QtWidgets.QLabel(_("〈filename〉"), self) + self.load_btn = QtWidgets.QPushButton(qicons.import_db, _("Load")) + self.load_btn.setToolTip(_("Load new data into this scenario table.")) + self.remove_btn = QtWidgets.QPushButton(qicons.delete, _("Delete")) + self.remove_btn.setToolTip(_("Remove this scenario table.")) self.table = ScenarioImportTable(self) self.scenario_df = pd.DataFrame(columns=SUPERSTRUCTURE) @@ -623,8 +615,8 @@ def load_action(self) -> None: if "default" not in df.columns: query = QtWidgets.QMessageBox.question( self, - "Default column not found", - "Attempt to load and include the 'default' scenario column?", + _("Default column not found"), + _("Try to load and include the 'default' scenario column?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No, ) @@ -636,20 +628,25 @@ def load_action(self) -> None: else: # this is a wrong file type msg = ( - "The Activity-Browser is attempting to import a scenario file.

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='"' ) + "

" ) critical = ABPopup.abCritical( - "Wrong file type", msg, QtWidgets.QPushButton("Cancel") + _("Wrong file type"), + msg, + QtWidgets.QPushButton(_("Cancel")), ) QtWidgets.QApplication.restoreOverrideCursor() critical.exec_() diff --git a/activity_browser/layouts/tabs/activity.py b/activity_browser/layouts/tabs/activity.py index 4f2f12e98..eedfd75b1 100644 --- a/activity_browser/layouts/tabs/activity.py +++ b/activity_browser/layouts/tabs/activity.py @@ -5,6 +5,7 @@ from activity_browser import ab_settings, project_settings, signals from activity_browser.bwutils import commontasks as bc +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from ...ui.icons import qicons @@ -43,9 +44,8 @@ def open_activity_tab(self, key: tuple, read_only: bool = True) -> None: # If this is a new or duplicated activity then we want to exit it # ditto check the Technosphere and Biosphere tables if not read_only: - for table in new_tab.grouped_tables: - if table.title() in ("Technosphere Flows:", "Biosphere Flows:"): - table.setChecked(True) + for group in new_tab.exchange_groups[1:3]: + group.setChecked(True) self.tabs[key] = new_tab tab_index = self.addTab(new_tab, bc.get_activity_name(act, str_length=30)) @@ -95,7 +95,7 @@ def __init__(self, key: tuple, read_only=True, parent=None): self.database = bd.Database(self.db_name) # Edit Activity checkbox - self.checkbox_edit_act = QtWidgets.QCheckBox("Edit Activity") + self.checkbox_edit_act = QtWidgets.QCheckBox(_("Edit activity")) self.checkbox_edit_act.setChecked(not self.read_only) self.checkbox_edit_act.toggled.connect(self.act_read_only_changed) @@ -108,33 +108,35 @@ def __init__(self, key: tuple, read_only=True, parent=None): # Activity Description checkbox self.checkbox_activity_description = QtWidgets.QCheckBox( - "Description", parent=self + _("Description"), parent=self ) self.checkbox_activity_description.clicked.connect( self.toggle_activity_description_visibility ) self.checkbox_activity_description.setChecked(not self.read_only) self.checkbox_activity_description.setToolTip( - "Show/hide the activity description" + _("Show or hide the activity description." ) ) self.toggle_activity_description_visibility() # Reveal/hide uncertainty columns - self.checkbox_uncertainty = QtWidgets.QCheckBox("Uncertainty") - self.checkbox_uncertainty.setToolTip("Show/hide the uncertainty columns") + self.checkbox_uncertainty = QtWidgets.QCheckBox(_("Uncertainty")) + self.checkbox_uncertainty.setToolTip( + _("Show or hide the uncertainty columns.") + ) self.checkbox_uncertainty.setChecked(False) self.checkbox_uncertainty.toggled.connect(self.show_exchange_uncertainty) # Reveal/hide exchange comment columns - self.checkbox_comment = QtWidgets.QCheckBox("Comments") - self.checkbox_comment.setToolTip("Show/hide the comment column") + self.checkbox_comment = QtWidgets.QCheckBox(_("Comments")) + self.checkbox_comment.setToolTip(_("Show or hide the comment column.")) self.checkbox_comment.setChecked(False) self.checkbox_comment.toggled.connect(self.show_comments) # Toolbar Layout toolbar = QtWidgets.QToolBar() self.graph_action = toolbar.addAction( - qicons.graph_explorer, "Show in Graph Explorer", self.open_graph + qicons.graph_explorer, _("Show in Graph Explorer"), self.open_graph ) toolbar.addWidget(self.checkbox_edit_act) toolbar.addWidget(self.checkbox_activity_description) @@ -155,10 +157,10 @@ def __init__(self, key: tuple, read_only=True, parent=None): self.downstream = DownstreamExchangeTable(self) self.exchange_groups = [ - DetailsGroupBox("Products:", self.production), - DetailsGroupBox("Technosphere Flows:", self.technosphere), - DetailsGroupBox("Biosphere Flows:", self.biosphere), - DetailsGroupBox("Downstream Consumers:", self.downstream), + DetailsGroupBox(_("Products:"), self.production), + DetailsGroupBox(_("Technosphere flows:"), self.technosphere), + DetailsGroupBox(_("Biosphere flows:"), self.biosphere), + DetailsGroupBox(_("Downstream consumers:"), self.downstream), ] self.exchange_groups[-1].setChecked(False) # hide Downstream table by default @@ -321,17 +323,22 @@ def db_read_only_changed(self, db_name: str, db_read_only: bool) -> None: def update_tooltips(self) -> None: if self.db_read_only: self.checkbox_edit_act.setToolTip( - "The database this activity belongs to is read-only." - " Enable database editing with checkbox in databases list" + _( + "The database containing this activity is read-only. Use the " + "checkbox in the database list to enable editing." + ) ) else: if self.read_only: self.checkbox_edit_act.setToolTip( - "Click to enable editing. Edits are saved automatically" + _("Click to enable editing. Changes are saved automatically.") ) else: self.checkbox_edit_act.setToolTip( - "Click to prevent further edits. Edits are saved automatically" + _( + "Click to prevent further editing. Changes are saved " + "automatically." + ) ) def update_style(self) -> None: diff --git a/activity_browser/layouts/tabs/base.py b/activity_browser/layouts/tabs/base.py index cd3d8e5c1..426c3250f 100644 --- a/activity_browser/layouts/tabs/base.py +++ b/activity_browser/layouts/tabs/base.py @@ -2,6 +2,8 @@ from PySide2.QtCore import Slot from PySide2.QtWidgets import QMessageBox, QWidget +from activity_browser.i18n import _ + class BaseRightTab(QWidget): """Extremely basic widget, to be created and used inside a QTabWidget @@ -11,7 +13,7 @@ class BaseRightTab(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.explain_text = "I explain what happens here" + self.explain_text = _("No explanation is available for this page yet.") def _connect_signals(self): """Used by child classes to wire up specific functionality to signals @@ -35,5 +37,9 @@ def explanation(self): on self.explain_text """ return QMessageBox.question( - self, "Explanation", self.explain_text, QMessageBox.Ok, QMessageBox.Ok + self, + _("Explanation"), + self.explain_text, + QMessageBox.Ok, + QMessageBox.Ok, ) diff --git a/activity_browser/layouts/tabs/history.py b/activity_browser/layouts/tabs/history.py index b86ff0d37..64bc95d6e 100644 --- a/activity_browser/layouts/tabs/history.py +++ b/activity_browser/layouts/tabs/history.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from PySide2 import QtCore, QtWidgets +from activity_browser.i18n import _ + from ...ui.style import header, horizontal_line from ...ui.tables import ActivitiesHistoryTable @@ -10,7 +12,7 @@ def __init__(self, parent=None): super(HistoryTab, self).__init__(parent) self.layout = QtWidgets.QVBoxLayout() self.layout.setAlignment(QtCore.Qt.AlignTop) - self.layout.addWidget(header("Activity selection history:")) + self.layout.addWidget(header(_("Activity selection history:"))) self.layout.addWidget(horizontal_line()) self.layout.addWidget(ActivitiesHistoryTable(self)) self.setLayout(self.layout) diff --git a/activity_browser/layouts/tabs/impact_categories.py b/activity_browser/layouts/tabs/impact_categories.py index bd2b74255..455549ab3 100644 --- a/activity_browser/layouts/tabs/impact_categories.py +++ b/activity_browser/layouts/tabs/impact_categories.py @@ -1,6 +1,7 @@ from PySide2 import QtCore, QtWidgets from activity_browser import signals +from activity_browser.i18n import _ from activity_browser.mod import bw2data as bd from ...ui.icons import qicons @@ -17,20 +18,24 @@ def __init__(self, parent, method_tuple): # Not visible when instantiated self.cf_table = MethodCharacterizationFactorsTable(self) self.cf_read_only_changed(False) # don't accept drops, don't allow editing. - self.hide_uncertainty = QtWidgets.QCheckBox("Hide uncertainty columns") + self.hide_uncertainty = QtWidgets.QCheckBox(_("Hide uncertainty columns")) self.hide_uncertainty.setChecked(True) self.read_only = True - self.editable = QtWidgets.QCheckBox("Edit Characterization Factors") + self.editable = QtWidgets.QCheckBox(_("Edit characterization factors")) self.editable.setToolTip( - "Make this impact category editable.\n" - "Please make a duplicate of this CF before modifying it." + _( + "Make this impact category editable.\nDuplicate it before " + "modifying its characterization factors." + ) ) self.editable.toggled.connect(self.cf_read_only_changed) toolbar = QtWidgets.QToolBar(self) toolbar.addWidget(self.hide_uncertainty) toolbar.addWidget(self.editable) container = QtWidgets.QVBoxLayout() - container.addWidget(header("Method: " + " - ".join(method_tuple))) + container.addWidget( + header(_("Method: {method}", method=" - ".join(method_tuple))) + ) container.addWidget(horizontal_line()) container.addWidget(toolbar) container.addWidget(self.cf_table) @@ -73,11 +78,11 @@ def __init__(self, parent): self.tree = MethodsTree(self) self.tree.setToolTip( - "Drag (groups of) impact categories to the calculation setup" + _("Drag impact categories or groups into the calculation setup.") ) self.table = MethodsTable(self) self.table.setToolTip( - "Drag (groups of) impact categories to the calculation setup" + _("Drag impact categories or groups into the calculation setup.") ) # auto-search @@ -88,35 +93,40 @@ def __init__(self, parent): # self.search_box = QtWidgets.QLineEdit() - self.search_box.setPlaceholderText("Search impact categories") + self.search_box.setPlaceholderText(_("Search impact categories")) self.search_box.setToolTip( - "If a large number of matches is found the\n" - "tree is not expanded automatically." + _( + "If many matches are found, the tree is not expanded " + "automatically." + ) ) self.search_button = QtWidgets.QToolButton() self.search_button.setIcon(qicons.search) self.search_button.setToolTip( - "Search impact categories.\n" - "If a large number of matches is found the\n" - "tree is not expanded automatically." + _( + "Search impact categories. If many matches are found, the " + "tree is not expanded automatically." + ) ) 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.mode_radio_tree = QtWidgets.QRadioButton("Tree view") + self.mode_radio_tree = QtWidgets.QRadioButton(_("Tree view")) self.mode_radio_tree.setChecked(True) self.mode_radio_tree.setToolTip( - "Tree view of impact categories\n" - "v CML 2001\n" - " v climate change\n" - " CML 2001, climate change, GWP 100a\n" - " ...\n" - "You can drag entire 'branches' of impact categories at once" + _( + "Tree view of impact categories\n" + "v CML 2001\n" + " v climate change\n" + " CML 2001, climate change, GWP 100a\n" + " ...\n" + "You can drag entire branches of impact categories at once." + ) ) # - self.mode_radio_list = QtWidgets.QRadioButton("List view") - self.mode_radio_list.setToolTip("List view of impact categories") + self.mode_radio_list = QtWidgets.QRadioButton(_("List view")) + self.mode_radio_list.setToolTip(_("List view of impact categories")) # search_layout = QtWidgets.QHBoxLayout() search_layout.addWidget(self.search_box) @@ -125,7 +135,7 @@ def __init__(self, parent): # mode_layout = QtWidgets.QHBoxLayout() mode_layout.setAlignment(QtCore.Qt.AlignTop) - mode_layout.addWidget(header("Impact Categories")) + mode_layout.addWidget(header(_("Impact categories"))) search_layout.addWidget(self.mode_radio_tree) search_layout.addWidget(self.mode_radio_list) # diff --git a/activity_browser/layouts/tabs/parameters.py b/activity_browser/layouts/tabs/parameters.py index 79b1f38a6..8379d33b8 100644 --- a/activity_browser/layouts/tabs/parameters.py +++ b/activity_browser/layouts/tabs/parameters.py @@ -10,6 +10,7 @@ from xlsxwriter.exceptions import FileCreateError from activity_browser import actions, signals +from activity_browser.i18n import _ from activity_browser.mod.bw2data import databases from activity_browser.signals import qparameters, qprojects @@ -19,6 +20,7 @@ from ...ui.tables import (ActivityParameterTable, BaseParameterTable, DataBaseParameterTable, ExchangesTable, ProjectParameterTable, ScenarioTable) +from ...ui.tables.models.scenarios import TooManyParametersError from .base import BaseRightTab @@ -40,7 +42,7 @@ def __init__(self, parent=None): "Scenarios": ParameterScenariosTab(self), } for name, tab in self.tabs.items(): - self.addTab(tab, name) + self.addTab(tab, _(name)) for tab in self.tabs.values(): if hasattr(tab, "build_tables"): @@ -93,7 +95,7 @@ class ABProjectParameter(ABParameterTable): def __init__(self, parent=None): super().__init__(parent) self.new_parameter_button = actions.ParameterNew.get_QButton(("", "")) - self.header = "Project:" + self.header = _("Project:") self.table = ProjectParameterTable(self) self.setLayout( @@ -104,7 +106,7 @@ def __init__(self, parent=None): class ABDatabaseParameter(ABParameterTable): def __init__(self, parent=None): super().__init__(parent) - self.header = "Database:" + self.header = _("Database:") self.new_parameter_button = actions.ParameterNew.get_QButton(("db", "")) @@ -124,8 +126,8 @@ def set_enabled(self, trigger): class ABActivityParameter(ABParameterTable): def __init__(self, parent=None): super().__init__(parent) - self.header = "Activity:" - self.parameter = QCheckBox("Show order column", self) + self.header = _("Activity:") + self.parameter = QCheckBox(_("Show order column"), self) self.table = ActivityParameterTable(self) self.setLayout(self.create_layout(self.header, self.parameter, self.table)) @@ -175,52 +177,38 @@ def __init__(self, parent=None): # self.database_header = header("Database:") # self.new_database_param = QPushButton(qicons.add, "New") # self.show_order = QCheckBox("Show order column", self) - self.show_database_params = QCheckBox("Database parameters", self) - self.show_database_params.setToolTip("Show/hide the database parameters") + self.show_database_params = QCheckBox(_("Database parameters"), self) + self.show_database_params.setToolTip( + _("Show or hide the database parameters.") + ) self.show_database_params.setChecked(True) # self.activity_header = header("Activity:") - self.show_activity_params = QCheckBox("Activity parameters", self) - self.show_activity_params.setToolTip("Show/hide the activity parameters") + self.show_activity_params = QCheckBox(_("Activity parameters"), self) + self.show_activity_params.setToolTip( + _("Show or hide the activity parameters.") + ) self.show_activity_params.setChecked(True) - self.comment_column = QCheckBox("Comments", self) - self.comment_column.setToolTip("Show/hide the comment column") + self.comment_column = QCheckBox(_("Comments"), self) + self.comment_column.setToolTip(_("Show or hide the comment column.")) self.hide_comment_column() - self.uncertainty_columns = QCheckBox("Uncertainty", self) - self.uncertainty_columns.setToolTip("Show/hide the uncertainty columns") + self.uncertainty_columns = QCheckBox(_("Uncertainty"), self) + self.uncertainty_columns.setToolTip( + _("Show or hide the uncertainty columns.") + ) self._construct_layout() self._connect_signals() - self.explain_text = """ -

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'] + '' - + '
Individual impact:    ' + roundNumber(n['ind']) + ' ' + n['LCIA_unit'] + ' (' + Math.round(n['ind_norm'] * 100) + '%)' - + '
Cumulative impact: ' + roundNumber(n['cum']) + ' ' + n['LCIA_unit'] + ' (' + Math.round(n['cum_norm'] * 100) + '%)'; + + '
' + window.abTranslations.individual_impact + ':    ' + roundNumber(n['ind']) + ' ' + n['LCIA_unit'] + ' (' + Math.round(n['ind_norm'] * 100) + '%)' + + '
' + window.abTranslations.cumulative_impact + ': ' + roundNumber(n['cum']) + ' ' + n['LCIA_unit'] + ' (' + Math.round(n['cum_norm'] * 100) + '%)'; } else { node_data.label = formatNodeText(n['name'], n['location']); node_data.labelType = "html"; @@ -777,4 +777,3 @@ new QWebChannel(qt.webChannelTransport, function (channel) { window.bridge.graph_ready.connect(cartographer.update_graph); window.bridge.style.connect(cartographer.update_svg_style); }); - diff --git a/activity_browser/static/navigator.html b/activity_browser/static/navigator.html index 8630e86cb..32d263c57 100644 --- a/activity_browser/static/navigator.html +++ b/activity_browser/static/navigator.html @@ -1,16 +1,21 @@ - - + + + + +{{PAGE_TITLE}} + -

Graph Navigator

+

{{PAGE_TITLE}}

@@ -18,12 +23,11 @@

Graph Navigator

- - + +
- diff --git a/activity_browser/static/sankey_navigator.html b/activity_browser/static/sankey_navigator.html index 77b127f19..bd89888ab 100644 --- a/activity_browser/static/sankey_navigator.html +++ b/activity_browser/static/sankey_navigator.html @@ -1,16 +1,21 @@ - - + + + + +{{PAGE_TITLE}} + -

Graph LCA

+

{{PAGE_TITLE}}

@@ -18,11 +23,10 @@

Graph LCA

- - + +
- diff --git a/activity_browser/static/startscreen/welcome.html b/activity_browser/static/startscreen/welcome.html index 6879333b0..3c6bfa867 100644 --- a/activity_browser/static/startscreen/welcome.html +++ b/activity_browser/static/startscreen/welcome.html @@ -1,16 +1,14 @@ - - -Welcome + + +Welcome diff --git a/activity_browser/static/startscreen/welcome.zh_CN.html b/activity_browser/static/startscreen/welcome.zh_CN.html new file mode 100644 index 000000000..868aedd56 --- /dev/null +++ b/activity_browser/static/startscreen/welcome.zh_CN.html @@ -0,0 +1,82 @@ + + + + +欢迎 + + + + + + + + + + +
Activity Browser 标志 +

欢迎使用 Activity Browser!

+Activity Browser +是一个开源图形用户界面,旨在提高使用 +Brightway +生命周期评价(LCA)框架时的工作效率。 +
+ +

获取最新消息

+AB 提供两个邮件列表: + + +

主要功能

+ + +

示例

+ + + + + + + + + + + +

LCA 结果概览

蒙特卡洛模拟

桑基图

LCA 结果蒙特卡洛模拟桑基图
+ +

更多信息和资源

+还可以访问 Activity Browser 的: + + +

参与贡献

+Activity Browser 是一个真正的开源项目,非常欢迎大家参与贡献。您可以通过以下方式参与:
+ +更详细的说明请参阅贡献指南
+ +

版权与许可证

+版权信息请参阅此处

+许可证信息请参阅此处

+ + diff --git a/activity_browser/translations/__init__.py b/activity_browser/translations/__init__.py new file mode 100644 index 000000000..6b8949e25 --- /dev/null +++ b/activity_browser/translations/__init__.py @@ -0,0 +1 @@ +"""Packaged Activity Browser user-interface translation catalogs.""" diff --git a/activity_browser/translations/zh_CN/actions.json b/activity_browser/translations/zh_CN/actions.json new file mode 100644 index 000000000..3793d9507 --- /dev/null +++ b/activity_browser/translations/zh_CN/actions.json @@ -0,0 +1,143 @@ +{ + "'Open *** in Graph Explorer'": "在图形浏览器中打开 ***", + "&Export this project...": "导出当前项目(&E)...", + "&Import a project...": "导入项目(&I)...", + "Add exchanges": "添加交换流", + "A calculation setup with this name already exists.": "已存在同名计算设置。", + "A database with this name already exists.": "已存在同名数据库。", + "An error occurred, check the logs for more information\n\n{error}": "发生错误,请查看日志了解详细信息\n\n{error}", + "An error occurred: {error_type}": "发生错误:{error_type}", + "Are you certain you want to delete {count} activities?": "确定要删除这 {count} 个活动吗?", + "Are you sure you want to delete database '{database}'?": "确定要删除数据库“{database}”吗?", + "Are you sure you want to delete database '{database}'? It contains {activities} activities and {exchanges} exchanges to other databases.": "确定要删除数据库“{database}”吗?其中包含 {activities} 个活动以及 {exchanges} 个指向其他数据库的交换流。", + "Are you sure you want to delete database '{database}'? It contains {activities} activities.": "确定要删除数据库“{database}”吗?其中包含 {activities} 个活动。", + "Are you sure you want to delete database '{database}'? It has {exchanges} exchanges to other databases.": "确定要删除数据库“{database}”吗?其中有 {exchanges} 个指向其他数据库的交换流。", + "Are you sure you want to delete this calculation setup?": "确定要删除此计算设置吗?", + "Clear broken parameter": "清除失效参数", + "Clear formula(s)": "清除公式", + "Delete": "删除", + "Delete ***": "删除 ***", + "Delete Impact Category": "删除影响类别", + "Delete database": "删除数据库", + "Delete exchange(s)": "删除交换流", + "Delete activities": "删除活动", + "Delete calculation setup: {name}": "删除计算设置:{name}", + "Delete database?": "删除数据库?", + "Delete parameter...": "删除参数...", + "Duplicate": "复制", + "Duplicate ***": "复制 ***", + "Duplicate Impact Category": "复制影响类别", + "Duplicate activity to new location": "将活动复制到新地点", + "Duplicate database...": "复制数据库...", + "Duplicate to other database": "复制到其他数据库", + "Duplicate to new database": "复制到新数据库", + "Duplicate '{name}'": "复制“{name}”", + "Duplicating database": "正在复制数据库", + "Duplicating existing database {source} to new database {target}:": "正在将现有数据库 {source} 复制为新数据库 {target}:", + "Exchanges for scenario difference file": "用于情景差异文件的交换流", + "Export database...": "导出数据库...", + "Import database...": "导入数据库...", + "Install default migrations": "安装默认数据迁移", + "Modify Activity": "修改活动", + "Modify Parameter": "修改参数", + "Modify amount": "修改数值", + "Modify exchange": "修改交换流", + "Modify parameter uncertainty": "修改参数不确定性", + "Modify uncertainty": "修改不确定性", + "Name of new calculation setup:": "新计算设置的名称:", + "Name of new database:": "新数据库名称:", + "Name of the duplicated calculation setup:": "复制后的计算设置名称:", + "New": "新建", + "New activity": "新建活动", + "New characterization factor": "新建特征化因子", + "New database...": "新建数据库...", + "New parameter...": "新建参数...", + "New name of this calculation setup:": "此计算设置的新名称:", + "Newer versions of the biosphere database may not always be compatible with older ecoinvent versions.\n\nUpdating the biosphere3 database cannot be undone!": "较新的生物圈数据库版本可能与较旧的 ecoinvent 版本不兼容。\n\n更新 biosphere3 数据库后无法撤销!", + "No target database": "没有目标数据库", + "No valid target databases are available. Create a new database or make an existing database writable.": "没有可用的目标数据库。请新建数据库,或将现有数据库设为可写。", + "Not possible": "无法执行", + "Open ***": "打开 ***", + "Open Impact Category": "打开影响类别", + "Plugin manager...": "插件管理器...", + "Please specify an activity name:": "请输入活动名称:", + "Copy activity to database": "将活动复制到数据库", + "Copy to {database} and open it in a new tab?": "复制到 {database} 并在新标签页中打开吗?", + "Copy {database}": "复制 {database}", + "Create new calculation setup": "新建计算设置", + "Create new database": "新建数据库", + "Create new technosphere activity": "新建技术圈活动", + "Relink the activity exchanges": "重新链接活动交换流", + "Relink the database": "重新链接数据库", + "Remove CF('s)": "移除特征化因子", + "Remove parameter uncertainty": "移除参数不确定性", + "Remove uncertainty": "移除不确定性", + "Remove uncertainty/-ies": "移除不确定性", + "Rename": "重命名", + "Rename '{name}'": "重命名“{name}”", + "Rename parameter...": "重命名参数...", + "Set up your project with default data": "使用默认数据设置项目", + "Settings...": "设置...", + "Switch project": "切换项目", + "Target database:": "目标数据库:", + "The calculation setup was successfully deleted.": "计算设置已成功删除。", + "Update biosphere...": "更新生物圈数据库...", + "Update biosphere3?": "更新 biosphere3?", + "\n\nOne or more activities have downstream processes. Deleting these activities will also remove their exchanges from downstream processes.": "\n\n一个或多个活动存在下游过程。删除这些活动也会移除它们与下游过程之间的交换流。", + "A project with this name already exists.": "已存在同名项目。", + "Activity must be of type 'process'; '{activity}' is of type '{activity_type}'.": "活动类型必须为“process”;“{activity}”的类型为“{activity_type}”。", + "Activity: Available to Activity and exchange parameters within the group": "活动参数:可供组内的活动参数和交换参数使用", + "Amount:": "数值:", + "Are you sure you want to delete {count} characterization factors?": "确定要删除这 {count} 个特征化因子吗?", + "Are you sure you want to delete {count} methods?": "确定要删除这 {count} 个方法吗?", + "Are you sure you want to delete this method?\n\n{method}": "确定要删除此方法吗?\n\n{method}", + "Choose a name for your project": "请输入项目名称", + "Choose project file to import": "选择要导入的项目文件", + "Choose project name": "选择项目名称", + "Choose where to export the project": "选择项目导出位置", + "Combined name:": "组合名称:", + "Could not find the project name file in the archive.": "压缩包中未找到项目名称文件。", + "Could not save changes": "无法保存更改", + "Create new project": "新建项目", + "Data:": "数据:", + "Database:": "数据库:", + "Database: Available to Database and Activity parameters of the same database": "数据库参数:可供同一数据库的数据库参数和活动参数使用", + "Delete characterization factors": "删除特征化因子", + "Delete method": "删除方法", + "Delete the project": "删除项目", + "Delete this database from the project": "从项目中删除此数据库", + "Duplicate characterization factors": "重复的特征化因子", + "Duplicate current project": "复制当前项目", + "Duplicate current project ({project}) with the new name:": "将当前项目({project})复制为新名称:", + "Duplicate the project": "复制项目", + "Export a database from this project": "从当前项目导出数据库", + "Export project to file": "将项目导出到文件", + "Exporting project": "正在导出项目", + "Fill out required values for the parameter": "填写参数的必填值", + "Import a new database": "导入新数据库", + "Import project from a file": "从文件导入项目", + "Importing project": "正在导入项目", + "Impact category name": "影响类别名称", + "Installing migrations": "正在安装数据迁移", + "Make a duplicate of this database": "复制此数据库", + "Make a new database": "新建数据库", + "Make a new project": "新建项目", + "Name of new project:": "新项目名称:", + "Name:": "名称:", + "Not allowed": "不允许", + "Not possible.": "无法执行。", + "One or more of these elementary flows already exist in this method. Duplicate flows will not be added.": "一个或多个基本流已存在于此方法中,重复的流不会再次添加。", + "

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.

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.

": "Activity Browser——Brightway2 的图形界面。

应用程序版本:{application_version}
bw2data 版本:{bw2data_version}
bw2io 版本:{bw2io_version}
bw2calc 版本:{bw2calc_version}

所有开发工作均在 github 上进行。

版权信息请参阅此页面

许可证信息请参阅此页面

", + "Bernoulli uncertainty": "伯努利分布不确定性", + "Beta PERT uncertainty": "Beta PERT 分布不确定性", + "Beta uncertainty": "Beta 分布不确定性", + "Discrete uniform uncertainty": "离散均匀分布不确定性", + "Gamma uncertainty": "伽马分布不确定性", + "Generalized extreme value uncertainty": "广义极值分布不确定性", + "Lognormal uncertainty": "对数正态分布不确定性", + "No uncertainty": "无不确定性", + "No classification": "未分类", + "Normal uncertainty": "正态分布不确定性", + "Student's T uncertainty": "学生 t 分布不确定性", + "Triangular uncertainty": "三角分布不确定性", + "Undefined or unknown uncertainty": "未定义或未知的不确定性", + "Undefined uncertainty": "未定义的不确定性", + "Uniform uncertainty": "均匀分布不确定性", + "Weibull uncertainty": "威布尔分布不确定性" +} diff --git a/activity_browser/translations/zh_CN/progress.json b/activity_browser/translations/zh_CN/progress.json new file mode 100644 index 000000000..3f88b35cc --- /dev/null +++ b/activity_browser/translations/zh_CN/progress.json @@ -0,0 +1,22 @@ +{ + "Applying biosphere patch: {patch}": "正在应用生物圈补丁:{patch}", + "Applying strategies": "正在应用处理策略", + "Creating migration: {migration}": "正在创建迁移:{migration}", + "Creating migrations": "正在创建迁移", + "Downloading from ecoinvent": "正在从 ecoinvent 下载", + "Extracting biosphere data": "正在提取生物圈数据", + "Extracting LCIA methods": "正在提取 LCIA 方法", + "Fixing versions in master data": "正在修正主数据中的版本", + "Fixing versions in unit process data": "正在修正单元过程数据中的版本", + "Fixing versions in unit process datasets": "正在修正单元过程数据集中的版本", + "Gathering LCIA methods": "正在收集 LCIA 方法", + "Importing ecoinvent": "正在导入 ecoinvent", + "Installing biosphere version {version}": "正在安装生物圈数据库版本 {version}", + "Installing legacy biosphere version {version}": "正在安装旧版生物圈数据库 {version}", + "Mapping LCIA methods": "正在映射 LCIA 方法", + "Unzipping download": "正在解压下载文件", + "Using close match {match} for predicted filename {filename}": "预测文件名 {filename} 不存在,改用最接近的 {match}", + "Writing biosphere database": "正在写入生物圈数据库", + "Writing ecoinvent database": "正在写入 ecoinvent 数据库", + "Writing LCIA methods": "正在写入 LCIA 方法" +} diff --git a/activity_browser/translations/zh_CN/qt.json b/activity_browser/translations/zh_CN/qt.json new file mode 100644 index 000000000..d2fad4279 --- /dev/null +++ b/activity_browser/translations/zh_CN/qt.json @@ -0,0 +1,33 @@ +{ + "&Cancel": "取消(&C)", + "&Finish": "完成(&F)", + "&Next >": "下一步(&N) >", + "&No": "否(&N)", + "&OK": "确定(&O)", + "&Save": "保存(&S)", + "&Yes": "是(&Y)", + "< &Back": "< 上一步(&B)", + "Abort": "中止", + "Apply": "应用", + "Cancel": "取消", + "Close": "关闭", + "Discard": "放弃", + "Finish": "完成", + "Help": "帮助", + "Hide Details...": "隐藏详细信息...", + "Ignore": "忽略", + "N/A": "不适用", + "Next": "下一步", + "No": "否", + "No to All": "全部否", + "OK": "确定", + "Open": "打开", + "Reset": "重置", + "Restore Defaults": "恢复默认值", + "Retry": "重试", + "Save": "保存", + "Save All": "全部保存", + "Show Details...": "显示详细信息...", + "Yes": "是", + "Yes to All": "全部是" +} diff --git a/activity_browser/translations/zh_CN/results.json b/activity_browser/translations/zh_CN/results.json new file mode 100644 index 000000000..6ef5000fc --- /dev/null +++ b/activity_browser/translations/zh_CN/results.json @@ -0,0 +1,107 @@ +{ + "\nThe reason for this is likely that there are no uncertain exchanges. Please check the checkboxes in the Monte Carlo tab.": "\n这很可能是因为没有包含不确定性的交换流。请检查蒙特卡洛页签中的复选框。", + "\nTo avoid this problem, increase the number of Monte Carlo iterations (for example, to more than 50).": "\n为避免此问题,请增加蒙特卡洛迭代次数(例如增加到 50 次以上)。", + "

Global Sensitivity Analysis (GSA)

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.

": "

全局敏感性分析(GSA)

全局敏感性分析是一类利用生成的分布来研究模型变量如何影响最终结果的方法。

Activity Browser 使用蒙特卡洛模拟为参考流生成分布,并据此执行 GSA。分析会在固定所选变量的同时重复随机模拟;比较变量的上下界,可以判断各固定变量对模型总体变异性的影响程度。

在 Wiki 中阅读详细说明

这些方法在一篇已发表的科学论文中有详细介绍。

", + "

Monte Carlo Analysis

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 模型中定义的不确定性分布进行随机抽样,并据此估计各参考流的预期结果分布。

具体来说,可以为模型中的部分或全部参数定义不确定性分布。模拟会反复从这些分布中抽样,形成最终的概率分布,用于描述参考流结果的预期变异程度。

在 Wiki 中进一步了解蒙特卡洛模拟

", + "

There are three ways of doing Contribution Analysis in Activity Browser:

- 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.

": "

Activity Browser 提供三种贡献分析方式:

- 基本流(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.
You can save only the failed scenario exchange (select Excerpt), or save the entire table with a column that marks failed relinking.
Press Cancel to stop.": "一个活动无法重新链接到本地数据库。
可以只保存失败的情景交换流(选择“摘录”),也可以保存整个表格并用一列标记重新链接失败的项目。
按“取消”停止。", + "Cancel": "取消", + "Choose where to save": "选择保存位置", + "Choose where to save the data table": "选择数据表保存位置", + "Combining scenario files": "正在合并情景文件", + "Duplicate flow exchanges": "重复的交换流", + "Excel (*.xlsx *.xls);;CSV (*.csv)": "Excel (*.xlsx *.xls);;CSV (*.csv)", + "Exchanges not found": "未找到交换流", + "Excerpt": "摘录", + "If left unchecked, the entire file is saved with an additional column indicating the status of exchange data in the scenario file.
Check this option to save a smaller excerpt containing only failed exchanges.": "若不勾选,将保存整个文件,并新增一列说明情景文件中交换流数据的状态。
勾选后只保存包含失败交换流的较小摘录。", + "Multiple activities could not be relinked to the local database.
The first five are shown. You can save only the failed scenario exchanges (select Excerpt), or save the entire table with a column that marks failed relinking.
Press Cancel to stop.": "多个活动无法重新链接到本地数据库。
这里显示前五项。可以只保存失败的情景交换流(选择“摘录”),也可以保存整个表格并用一列标记重新链接失败的项目。
按“取消”停止。", + "Non-numeric input data": "输入中包含非数值数据", + "No scenario exchange data": "没有情景交换流数据", + "OK": "确定", + "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}, {input_code})与({output_database}, {output_code})之间的活动有一项不在指定数据库中。请检查情景文件中该交换流的两个键及其对应数据库。", + "Please enter a valid value before calculating LCA results again.": "请先输入有效值,再重新计算 LCA 结果。", + "Reference flows equal zero": "参考流为零", + "Save": "保存", + "Scenario key error": "情景键错误", + "Scenarios being dropped": "将舍弃部分情景", + "The scenario files could not be combined because they have no scenario columns in common. Extending scenarios requires at least one scenario shared by all files.": "无法合并情景文件,因为这些文件没有共同的情景列。扩展情景时,所有文件必须至少共享一个情景。", + "Scenarios": "情景" +} diff --git a/activity_browser/translations/zh_CN/tables.json b/activity_browser/translations/zh_CN/tables.json new file mode 100644 index 000000000..b56052b27 --- /dev/null +++ b/activity_browser/translations/zh_CN/tables.json @@ -0,0 +1,100 @@ +{ + "# CFs": "特征化因子数量", + "Activity": "活动", + "Author": "作者", + "Add": "加", + "Additional functions": "其他函数", + "Amount": "数值", + "Build a formula": "构建公式", + "Cannot set activity parameters on read-only databases": "不能为只读数据库中的活动设置参数", + "Collapse all sub levels": "折叠所有下级", + "Comment": "备注", + "Compartments": "区室", + "Category": "类别", + "Categories": "类别", + "Copy to clipboard": "复制到剪贴板", + "Database": "数据库", + "Delete activities": "删除活动", + "Delete activity": "删除活动", + "Delete parameter": "删除参数", + "Depends": "依赖项", + "Divide": "除", + "Drag activities from the activities table to include them as reference flows.\nClick and drag to reorder rows.\nHold Ctrl while clicking to select multiple rows to open or remove.": "从活动表拖入活动,将其纳入参考流。\n单击并拖动可调整行的顺序。\n按住 Ctrl 并单击,可选择多行进行打开或移除。", + "Drag impact categories from the tree or table to include them.\nClick and drag to reorder rows.\nHold Ctrl while clicking to select multiple rows to open or remove.": "从影响类别树或表中拖入影响类别。\n单击并拖动可调整行的顺序。\n按住 Ctrl 并单击,可选择多行进行打开或移除。", + "Duplicate activities": "复制活动", + "Duplicate activity": "复制活动", + "Exchanges for scenario difference file": "用于情景差异文件的交换流", + "Expand all sub levels": "展开所有下级", + "Formula": "公式", + "Flow Name": "流名称", + "Group": "组", + "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.": "除了计算器上的按钮外,参数公式还可以使用许多 Python 和 NumPy 函数。若两者的函数名称相同,则优先使用 NumPy 函数。\n\n更完整的列表请参阅 Python 的 math 模块文档或 NumPy 的 ufunc 文档。\n\n公式的结果必须是标量值。", + "Inspect impact category": "查看影响类别", + "Location": "地点", + "Modified": "已修改", + "Modify uncertainty": "修改不确定性", + "More...": "更多...", + "Multiply": "乘", + "Name": "名称", + "NOT FOUND: {value}": "未找到:{value}", + "Not allowed": "不允许", + "Open activities": "打开活动", + "Open activities in Graph Explorer": "在图形浏览器中打开活动", + "Open activity": "打开活动", + "Open activity in Graph Explorer": "在图形浏览器中打开活动", + "Open in new tab": "在新标签页中打开", + "Order": "顺序", + "Product": "产品", + "Read-only": "只读", + "Records": "记录数", + "Rest (+)": "其余(正)", + "Rest (-)": "其余(负)", + "Relink the activity's exchanges": "重新链接该活动的交换流", + "Relink the selected activities' exchanges": "重新链接所选活动的交换流", + "Remove plugin from project?": "从项目中移除此插件吗?", + "Remove row": "移除行", + "Remove rows": "移除所选行", + "Remove uncertainty": "移除不确定性", + "Rename parameter": "重命名参数", + "Select and order items": "选择项目并排序", + "Scenario name": "情景名称", + "Score": "得分", + "Subtract": "减", + "This will remove all data created by the plugin.": "这将移除该插件创建的所有数据。", + "Type": "类型", + "Total": "总计", + "Uncertainty": "不确定性", + "Unit": "单位", + "Version": "版本", + "X to the power of 2": "X 的平方", + "activity": "活动", + "amount": "数值", + "author": "作者", + "categories": "类别", + "code": "代码", + "comment": "备注", + "database": "数据库", + "default": "默认值", + "formula": "公式", + "group": "组", + "index": "索引", + "key": "数据键", + "loc": "位置参数", + "location": "地点", + "maximum": "最大值", + "method": "方法标识", + "minimum": "最小值", + "name": "名称", + "order": "顺序", + "pedigree": "数据质量矩阵", + "product": "产品", + "relative share": "相对占比", + "reference product": "参考产品", + "scale": "尺度参数", + "shape": "形状参数", + "type": "类型", + "uncertainty type": "不确定性类型", + "unit": "单位", + "use": "启用", + "version": "版本" +} diff --git a/activity_browser/translations/zh_CN/tabs.json b/activity_browser/translations/zh_CN/tabs.json new file mode 100644 index 000000000..d7647e769 --- /dev/null +++ b/activity_browser/translations/zh_CN/tabs.json @@ -0,0 +1,105 @@ +{ + "

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( + " 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"]