oqtopus 0.1.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ import os
2
+ import shutil
3
+
4
+ from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox, QWidget
5
+
6
+ from ..core.module_package import ModulePackage
7
+ from ..utils.plugin_utils import PluginUtils
8
+ from ..utils.qt_utils import CriticalMessageBox, QtUtils
9
+
10
+ DIALOG_UI = PluginUtils.get_ui_class("project_widget.ui")
11
+
12
+
13
+ class ProjectWidget(QWidget, DIALOG_UI):
14
+
15
+ def __init__(self, parent=None):
16
+ QWidget.__init__(self, parent)
17
+ self.setupUi(self)
18
+
19
+ self.project_install_pushButton.clicked.connect(self.__projectInstallClicked)
20
+ self.project_seeChangelog_pushButton.clicked.connect(self.__projectSeeChangelogClicked)
21
+
22
+ self.__current_module_package = None
23
+
24
+ def setModulePackage(self, module_package: ModulePackage):
25
+ self.__current_module_package = module_package
26
+ self.__packagePrepareGetProjectFilename()
27
+
28
+ def __packagePrepareGetProjectFilename(self):
29
+ asset_project = self.__current_module_package.asset_project
30
+ if asset_project is None:
31
+ self.project_info_label.setText(
32
+ self.tr("No project asset available for this module version.")
33
+ )
34
+ QtUtils.setForegroundColor(self.project_info_label, PluginUtils.COLOR_WARNING)
35
+ QtUtils.setFontItalic(self.project_info_label, True)
36
+ return
37
+
38
+ # Search for QGIS project file in self.__package_dir
39
+ project_file_dir = os.path.join(asset_project.package_dir, "project")
40
+
41
+ # Check if the directory exists
42
+ if not os.path.exists(project_file_dir):
43
+ self.project_info_label.setText(
44
+ self.tr(f"Project directory '{project_file_dir}' does not exist.")
45
+ )
46
+ QtUtils.setForegroundColor(self.project_info_label, PluginUtils.COLOR_WARNING)
47
+ QtUtils.setFontItalic(self.project_info_label, True)
48
+ return
49
+
50
+ self.__project_file = None
51
+ for root, dirs, files in os.walk(project_file_dir):
52
+ for file in files:
53
+ if file.endswith(".qgz") or file.endswith(".qgs"):
54
+ self.__project_file = os.path.join(root, file)
55
+ break
56
+
57
+ if self.__project_file:
58
+ break
59
+
60
+ if self.__project_file is None:
61
+ self.project_info_label.setText(
62
+ self.tr(f"No QGIS project file (.qgz or .qgs) found into {project_file_dir}."),
63
+ )
64
+ QtUtils.setForegroundColor(self.project_info_label, PluginUtils.COLOR_WARNING)
65
+ QtUtils.setFontItalic(self.db_database_label, True)
66
+ return
67
+
68
+ self.project_info_label.setText(
69
+ self.tr(self.__project_file),
70
+ )
71
+ QtUtils.setForegroundColor(self.project_info_label, PluginUtils.COLOR_GREEN)
72
+ QtUtils.setFontItalic(self.db_database_label, False)
73
+
74
+ def __projectInstallClicked(self):
75
+
76
+ if self.__current_module_package is None:
77
+ QMessageBox.warning(
78
+ self,
79
+ self.tr("Error"),
80
+ self.tr("Please select a module and version first."),
81
+ )
82
+ return
83
+
84
+ if self.module_package_comboBox.currentData() is None:
85
+ QMessageBox.warning(
86
+ self,
87
+ self.tr("Error"),
88
+ self.tr("Please select a module version first."),
89
+ )
90
+ return
91
+
92
+ asset_project = self.module_package_comboBox.currentData().asset_project
93
+ if asset_project is None:
94
+ QMessageBox.warning(
95
+ self,
96
+ self.tr("Error"),
97
+ self.tr("No project asset available for this module version."),
98
+ )
99
+ return
100
+
101
+ package_dir = asset_project.package_dir
102
+ if package_dir is None:
103
+ CriticalMessageBox(
104
+ self.tr("Error"), self.tr("No valid package directory available."), None, self
105
+ ).exec()
106
+ return
107
+
108
+ # Search for QGIS project file in package_dir
109
+ project_file_dir = os.path.join(package_dir, "project")
110
+
111
+ # Check if the directory exists
112
+ if not os.path.exists(project_file_dir):
113
+ CriticalMessageBox(
114
+ self.tr("Error"),
115
+ self.tr(f"Project directory '{project_file_dir}' does not exist."),
116
+ None,
117
+ self,
118
+ ).exec()
119
+ return
120
+
121
+ self.__project_file = None
122
+ for root, dirs, files in os.walk(project_file_dir):
123
+ print(f"Searching for QGIS project file in {root}: {files}")
124
+ for file in files:
125
+ if file.endswith(".qgz") or file.endswith(".qgs"):
126
+ self.__project_file = os.path.join(root, file)
127
+ break
128
+
129
+ if self.__project_file:
130
+ break
131
+
132
+ if self.__project_file is None:
133
+ CriticalMessageBox(
134
+ self.tr("Error"),
135
+ self.tr(f"No QGIS project file (.qgz or .qgs) found into {project_file_dir}."),
136
+ None,
137
+ self,
138
+ ).exec()
139
+ return
140
+
141
+ install_destination = QFileDialog.getExistingDirectory(
142
+ self,
143
+ self.tr("Select installation directory"),
144
+ "",
145
+ QFileDialog.Option.ShowDirsOnly,
146
+ )
147
+
148
+ if not install_destination:
149
+ return
150
+
151
+ # Copy the project file to the selected directory
152
+ try:
153
+ shutil.copy(self.__project_file, install_destination)
154
+ QMessageBox.information(
155
+ self,
156
+ self.tr("Project installed"),
157
+ self.tr(
158
+ f"Project file '{self.__project_file}' has been copied to '{install_destination}'."
159
+ ),
160
+ )
161
+ except Exception as e:
162
+ QMessageBox.critical(
163
+ self,
164
+ self.tr("Error"),
165
+ self.tr(f"Failed to copy project file: {e}"),
166
+ )
167
+ return
168
+
169
+ def __projectSeeChangelogClicked(self):
170
+ self.__seeChangeLogClicked()
@@ -0,0 +1,37 @@
1
+ from qgis.PyQt.QtWidgets import QApplication, QDialog, QMessageBox, QStyle
2
+
3
+ from ..utils.plugin_utils import PluginUtils
4
+
5
+ DIALOG_UI = PluginUtils.get_ui_class("settings_dialog.ui")
6
+
7
+
8
+ class SettingsDialog(QDialog, DIALOG_UI):
9
+ def __init__(self, parent=None):
10
+ QDialog.__init__(self, parent)
11
+ self.setupUi(self)
12
+
13
+ self.githubToken_lineEdit.setText(PluginUtils.get_github_token())
14
+
15
+ self.helpButton.setIcon(
16
+ QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogHelpButton)
17
+ )
18
+ self.helpButton.clicked.connect(self.__show_github_token_help)
19
+
20
+ def accept(self):
21
+ PluginUtils.set_github_token(self.githubToken_lineEdit.text())
22
+ super().accept()
23
+
24
+ def __show_github_token_help(self):
25
+ QMessageBox.information(
26
+ self,
27
+ "GitHub Access Token Help",
28
+ "<b>GitHub Access Token</b><br>"
29
+ "Oqtopus needs to download release data from GitHub to work properly. "
30
+ "GitHub limits the number of requests that can be made without authentication. "
31
+ "A personal access token is required to access private repositories or to increase API rate limits.<br><br>"
32
+ "To generate a token:<br>"
33
+ "1. Go to <a href='https://github.com/settings/tokens'>GitHub Personal Access Tokens</a>.<br>"
34
+ "2. Click <b>Generate new token</b>.<br>"
35
+ "3. Select the <code>repo</code> scope for most operations.<br>"
36
+ "4. Copy and paste the generated token here.",
37
+ )
oqtopus/oqtopus.py ADDED
@@ -0,0 +1,67 @@
1
+ import sys
2
+ import types
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ # Create fake qgis.PyQt modules that point to PyQt5 modules
8
+ try:
9
+ pyqt_core = __import__("PyQt6.QtCore", fromlist=[""])
10
+ pyqt_gui = __import__("PyQt6.QtGui", fromlist=[""])
11
+ pyqt_network = __import__("PyQt6.QtNetwork", fromlist=[""])
12
+ pyqt_widgets = __import__("PyQt6.QtWidgets", fromlist=[""])
13
+ pyqt_uic = __import__("PyQt6.uic", fromlist=[""])
14
+ except ModuleNotFoundError:
15
+ pyqt_core = __import__("PyQt5.QtCore", fromlist=[""])
16
+ pyqt_gui = __import__("PyQt5.QtGui", fromlist=[""])
17
+ pyqt_network = __import__("PyQt5.QtNetwork", fromlist=[""])
18
+ pyqt_widgets = __import__("PyQt5.QtWidgets", fromlist=[""])
19
+ pyqt_uic = __import__("PyQt5.uic", fromlist=[""])
20
+
21
+ # Create the qgis, qgis.PyQt, and submodules in sys.modules
22
+ qgis = types.ModuleType("qgis")
23
+ pyqt = types.ModuleType("qgis.PyQt")
24
+ pyqt.QtCore = pyqt_core
25
+ pyqt.QtGui = pyqt_gui
26
+ pyqt.QtNetwork = pyqt_network
27
+ pyqt.QtWidgets = pyqt_widgets
28
+ pyqt.uic = pyqt_uic
29
+
30
+ qgis.PyQt = pyqt
31
+ sys.modules["qgis"] = qgis
32
+ sys.modules["qgis.PyQt"] = pyqt
33
+ sys.modules["qgis.PyQt.QtCore"] = pyqt_core
34
+ sys.modules["qgis.PyQt.QtGui"] = pyqt_gui
35
+ sys.modules["qgis.PyQt.QtNetwork"] = pyqt_network
36
+ sys.modules["qgis.PyQt.QtWidgets"] = pyqt_widgets
37
+ sys.modules["qgis.PyQt.uic"] = pyqt_uic
38
+
39
+ from qgis.PyQt.QtGui import QIcon # noqa: E402
40
+
41
+ from .core.modules_config import ModulesConfig # noqa: E402
42
+ from .gui.main_dialog import MainDialog # noqa: E402
43
+ from .utils.plugin_utils import PluginUtils # noqa: E402
44
+
45
+
46
+ def main():
47
+ app = pyqt_widgets.QApplication(sys.argv)
48
+ icon = QIcon("oqtopus/icons/oqtopus-logo.png")
49
+ app.setWindowIcon(icon)
50
+
51
+ PluginUtils.init_logger()
52
+
53
+ conf_path = Path(__file__).parent / "default_config.yaml"
54
+
55
+ with conf_path.open() as f:
56
+ data = yaml.safe_load(f)
57
+ modules_config = ModulesConfig(**data)
58
+
59
+ dialog = MainDialog(modules_config)
60
+ dialog.setWindowIcon(icon)
61
+ dialog.show()
62
+
63
+ sys.exit(app.exec())
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,184 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+ from qgis.PyQt.QtGui import QIcon
5
+ from qgis.PyQt.QtWidgets import QAction, QApplication
6
+
7
+ from oqtopus.core.modules_config import ModulesConfig
8
+
9
+ from .gui.about_dialog import AboutDialog
10
+ from .gui.main_dialog import MainDialog
11
+ from .utils.plugin_utils import PluginUtils, logger
12
+
13
+
14
+ class OqtopusPlugin:
15
+
16
+ def __init__(self, iface):
17
+ """Constructor.
18
+
19
+ :param iface: An interface instance that will be passed to this class
20
+ which provides the hook by which you can manipulate the QGIS
21
+ application at run time.
22
+ :type iface: QgsInterface
23
+ """
24
+ # Save reference to the QGIS interface
25
+ self.iface = iface
26
+ self.canvas = iface.mapCanvas()
27
+
28
+ self.__version__ = PluginUtils.get_plugin_version()
29
+
30
+ PluginUtils.init_logger()
31
+
32
+ logger.info("")
33
+ logger.info(f"Starting {PluginUtils.PLUGIN_NAME} plugin version {self.__version__}")
34
+
35
+ self.actions = []
36
+ self.main_menu_name = self.tr(f"&{PluginUtils.PLUGIN_NAME}")
37
+
38
+ conf_path = Path(__file__).parent / "default_config.yaml"
39
+
40
+ with conf_path.open() as f:
41
+ data = yaml.safe_load(f)
42
+ self.modules_config = ModulesConfig(**data)
43
+
44
+ # noinspection PyMethodMayBeStatic
45
+ def tr(self, source_text):
46
+ """
47
+ This does not inherit from QObject but for the translation to work
48
+ :rtype : unicode
49
+ :param source_text: The text to translate
50
+ :return: The translated text
51
+ """
52
+ # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
53
+ return QApplication.translate("OqtopusPlugin", source_text)
54
+
55
+ def add_action(
56
+ self,
57
+ icon_path,
58
+ text,
59
+ callback,
60
+ enabled_flag=True,
61
+ add_to_menu=True,
62
+ add_to_toolbar=True,
63
+ status_tip=None,
64
+ whats_this=None,
65
+ parent=None,
66
+ ):
67
+ """Add a toolbar icon to the toolbar.
68
+
69
+ :param icon_path: Path to the icon for this action. Can be a resource
70
+ path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
71
+ :type icon_path: str
72
+
73
+ :param text: Text that should be shown in menu items for this action.
74
+ :type text: str
75
+
76
+ :param callback: Function to be called when the action is triggered.
77
+ :type callback: function
78
+
79
+ :param enabled_flag: A flag indicating if the action should be enabled
80
+ by default. Defaults to True.
81
+ :type enabled_flag: bool
82
+
83
+ :param add_to_menu: Flag indicating whether the action should also
84
+ be added to the menu. Defaults to True.
85
+ :type add_to_menu: bool
86
+
87
+ :param add_to_toolbar: Flag indicating whether the action should also
88
+ be added to the toolbar. Defaults to True.
89
+ :type add_to_toolbar: bool
90
+
91
+ :param status_tip: Optional text to show in a popup when mouse pointer
92
+ hovers over the action.
93
+ :type status_tip: str
94
+
95
+ :param parent: Parent widget for the new action. Defaults None.
96
+ :type parent: QWidget
97
+
98
+ :param whats_this: Optional text to show in the status bar when the
99
+ mouse pointer hovers over the action.
100
+
101
+ :returns: The action that was created. Note that the action is also
102
+ added to self.actions list.
103
+ :rtype: QAction
104
+ """
105
+
106
+ icon = QIcon(icon_path)
107
+ action = QAction(icon, text, parent)
108
+ action.triggered.connect(callback)
109
+ action.setEnabled(enabled_flag)
110
+
111
+ if status_tip is not None:
112
+ action.setStatusTip(status_tip)
113
+
114
+ if whats_this is not None:
115
+ action.setWhatsThis(whats_this)
116
+
117
+ if add_to_toolbar:
118
+ # Adds plugin icon to Plugins toolbar
119
+ self.iface.addToolBarIcon(action)
120
+
121
+ if add_to_menu:
122
+ self.iface.addPluginToMenu(self.main_menu_name, action)
123
+
124
+ self.actions.append(action)
125
+
126
+ return action
127
+
128
+ def initGui(self):
129
+ """Create the menu entries and toolbar icons inside the QGIS GUI."""
130
+ self.add_action(
131
+ icon_path=PluginUtils.get_plugin_icon_path("oqtopus-logo.png"),
132
+ text=self.tr("Show &main dialog"),
133
+ callback=self.show_main_dialog,
134
+ parent=self.iface.mainWindow(),
135
+ )
136
+ self.add_action(
137
+ icon_path=None,
138
+ text=self.tr("Show &log folder"),
139
+ callback=self.show_logs_folder,
140
+ parent=self.iface.mainWindow(),
141
+ add_to_toolbar=False,
142
+ )
143
+ self.add_action(
144
+ icon_path=PluginUtils.get_plugin_icon_path("oqtopus-logo.png"),
145
+ text=self.tr("&About"),
146
+ callback=self.show_about_dialog,
147
+ parent=self.iface.mainWindow(),
148
+ add_to_toolbar=False,
149
+ )
150
+
151
+ self._get_main_menu_action().setIcon(
152
+ PluginUtils.get_plugin_icon("oqtopus-logo.png"),
153
+ )
154
+
155
+ def unload(self):
156
+ """Removes the plugin menu item and icon from QGIS GUI."""
157
+ for action in self.actions:
158
+ self.iface.removePluginMenu(self.main_menu_name, action)
159
+ self.iface.removeToolBarIcon(action)
160
+
161
+ def show_main_dialog(self):
162
+ main_dialog = MainDialog(self.modules_config, self.iface.mainWindow())
163
+ main_dialog.exec()
164
+
165
+ def show_logs_folder(self):
166
+ PluginUtils.open_logs_folder()
167
+
168
+ def show_about_dialog(self):
169
+ about_dialog = AboutDialog(self.iface.mainWindow())
170
+ about_dialog.exec()
171
+
172
+ def _get_main_menu_action(self):
173
+ actions = self.iface.pluginMenu().actions()
174
+ result_actions = [action for action in actions if action.text() == self.main_menu_name]
175
+
176
+ # OSX does not support & in the menu title
177
+ if not result_actions:
178
+ result_actions = [
179
+ action
180
+ for action in actions
181
+ if action.text() == self.main_menu_name.replace("&", "")
182
+ ]
183
+
184
+ return result_actions[0]
oqtopus/ui/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,172 @@
1
+ """
2
+ /***************************************************************************
3
+ Plugin Utils
4
+ -------------------
5
+ begin : 28.4.2018
6
+ copyright : (C) 2018 by OPENGIS.ch
7
+ email : matthias@opengis.ch
8
+ ***************************************************************************/
9
+
10
+ /***************************************************************************
11
+ * *
12
+ * This program is free software; you can redistribute it and/or modify *
13
+ * it under the terms of the GNU General Public License as published by *
14
+ * the Free Software Foundation; either version 2 of the License, or *
15
+ * (at your option) any later version. *
16
+ * *
17
+ ***************************************************************************/
18
+ """
19
+
20
+ import logging
21
+ import os
22
+ from logging import LogRecord
23
+ from logging.handlers import TimedRotatingFileHandler
24
+
25
+ from qgis.PyQt.QtCore import (
26
+ QDir,
27
+ QFileInfo,
28
+ QObject,
29
+ QSettings,
30
+ QStandardPaths,
31
+ QUrl,
32
+ pyqtSignal,
33
+ )
34
+ from qgis.PyQt.QtGui import QColor, QDesktopServices, QIcon
35
+ from qgis.PyQt.uic import loadUiType
36
+
37
+ logger = logging.getLogger("oqtopus")
38
+
39
+
40
+ class PluginUtils:
41
+
42
+ PLUGIN_NAME = "Oqtopus"
43
+
44
+ logsDirectory = ""
45
+
46
+ COLOR_GREEN = QColor(12, 167, 137)
47
+ COLOR_WARNING = QColor(255, 165, 0)
48
+
49
+ @staticmethod
50
+ def plugin_root_path():
51
+ """
52
+ Returns the root path of the plugin
53
+ """
54
+ return os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
55
+
56
+ @staticmethod
57
+ def plugin_temp_path():
58
+ plugin_basename = PluginUtils.plugin_root_path().split(os.sep)[-1]
59
+
60
+ plugin_temp_dir = os.path.join(
61
+ QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation),
62
+ plugin_basename,
63
+ )
64
+ if not os.path.exists(plugin_temp_dir):
65
+ os.makedirs(plugin_temp_dir)
66
+
67
+ return plugin_temp_dir
68
+
69
+ @staticmethod
70
+ def get_plugin_icon_path(icon_filename):
71
+ return os.path.join(PluginUtils.plugin_root_path(), "icons", icon_filename)
72
+
73
+ @staticmethod
74
+ def get_plugin_icon(icon_filename):
75
+ return QIcon(PluginUtils.get_plugin_icon_path(icon_filename=icon_filename))
76
+
77
+ @staticmethod
78
+ def get_ui_class(ui_file):
79
+ """Get UI Python class from .ui file.
80
+ Can be filename.ui or subdirectory/filename.ui
81
+ :param ui_file: The file of the ui in svir.ui
82
+ :type ui_file: str
83
+ """
84
+ os.path.sep.join(ui_file.split("/"))
85
+ ui_file_path = os.path.abspath(os.path.join(PluginUtils.plugin_root_path(), "ui", ui_file))
86
+ return loadUiType(ui_file_path)[0]
87
+
88
+ @staticmethod
89
+ def get_metadata_file_path():
90
+ return os.path.join(PluginUtils.plugin_root_path(), "metadata.txt")
91
+
92
+ @staticmethod
93
+ def get_plugin_version():
94
+ ini_text = QSettings(PluginUtils.get_metadata_file_path(), QSettings.Format.IniFormat)
95
+ return ini_text.value("version")
96
+
97
+ @staticmethod
98
+ def init_logger():
99
+ PluginUtils.logsDirectory = f"{PluginUtils.plugin_root_path()}/logs"
100
+
101
+ directory = QDir(PluginUtils.logsDirectory)
102
+ if not directory.exists():
103
+ directory.mkpath(PluginUtils.logsDirectory)
104
+
105
+ if directory.exists():
106
+ logfile = QFileInfo(directory, "Oqtopus.log")
107
+
108
+ # Handler for files rotation, create one log per day
109
+ rotationHandler = TimedRotatingFileHandler(
110
+ logfile.filePath(), when="midnight", backupCount=10
111
+ )
112
+
113
+ # Configure logging
114
+ logging.basicConfig(
115
+ level=logging.DEBUG,
116
+ format="%(asctime)s %(levelname)-7s %(message)s",
117
+ handlers=[rotationHandler],
118
+ )
119
+ else:
120
+ logger.error(f"Can't create log files directory '{PluginUtils.logsDirectory}'.")
121
+
122
+ @staticmethod
123
+ def open_logs_folder():
124
+ print(f"Opening logs folder {PluginUtils.logsDirectory}")
125
+ QDesktopServices.openUrl(QUrl.fromLocalFile(PluginUtils.logsDirectory))
126
+
127
+ @staticmethod
128
+ def open_log_file():
129
+ log_file_path = os.path.join(PluginUtils.logsDirectory, "Oqtopus.log")
130
+ if os.path.exists(log_file_path):
131
+ QDesktopServices.openUrl(QUrl.fromLocalFile(log_file_path))
132
+ else:
133
+ logger.error(f"Log file '{log_file_path}' does not exist.")
134
+
135
+ @staticmethod
136
+ def get_github_token():
137
+ settings = QSettings()
138
+ return settings.value("oqtopus/github_token", type=str)
139
+
140
+ @staticmethod
141
+ def set_github_token(token: str):
142
+ settings = QSettings()
143
+ settings.setValue("oqtopus/github_token", token)
144
+
145
+ @staticmethod
146
+ def get_github_headers():
147
+ token = PluginUtils.get_github_token()
148
+ headers = {}
149
+ if token:
150
+ headers["Authorization"] = f"token {token}"
151
+ return headers
152
+
153
+
154
+ class LoggingBridge(logging.Handler, QObject):
155
+
156
+ loggedLine = pyqtSignal(LogRecord, str)
157
+
158
+ def __init__(self, level=logging.NOTSET, excluded_modules=[]):
159
+ QObject.__init__(self)
160
+ logging.Handler.__init__(self, level)
161
+
162
+ self.formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
163
+
164
+ self.excluded_modules = excluded_modules
165
+
166
+ def filter(self, record):
167
+ return record.name not in self.excluded_modules
168
+
169
+ def emit(self, record):
170
+ log_entry = self.format(record)
171
+ print(log_entry)
172
+ self.loggedLine.emit(record, log_entry)