oqtopus 0.2.0__py3-none-any.whl → 1.0.0__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.
@@ -1,10 +1,12 @@
1
1
  import os
2
+ import re
2
3
  import shutil
3
4
  import zipfile
4
5
 
5
6
  import requests
6
7
  from qgis.PyQt.QtCore import QThread, pyqtSignal
7
8
 
9
+ from ..core.module_package import ModulePackage
8
10
  from ..utils.plugin_utils import PluginUtils, logger
9
11
 
10
12
 
@@ -12,13 +14,36 @@ class PackagePrepareTaskCanceled(Exception):
12
14
  pass
13
15
 
14
16
 
17
+ def sanitize_filename(name: str) -> str:
18
+ """Sanitize a string to be safe for use as a filename/directory name.
19
+
20
+ Replaces characters that are problematic on Windows or other filesystems.
21
+ For PR names like "#909 some title", extracts just the number to keep paths short.
22
+ """
23
+ # For PR-style names starting with #, extract just the number
24
+ pr_match = re.match(r"#?(\d+)", name)
25
+ if pr_match:
26
+ return f"PR_{pr_match.group(1)}"
27
+
28
+ # Replace characters that are invalid on Windows: < > : " / \\ | ? * #
29
+ # Also replace spaces to avoid path issues
30
+ sanitized = re.sub(r'[<>:"/\\|?*#\s]+', "_", name)
31
+ # Remove leading/trailing underscores and dots
32
+ sanitized = sanitized.strip("_.")
33
+ # Limit length to avoid Windows MAX_PATH issues (260 char limit)
34
+ # Keep it very short since zip contents add more nested paths
35
+ if len(sanitized) > 40:
36
+ sanitized = sanitized[:40]
37
+ return sanitized
38
+
39
+
15
40
  class PackagePrepareTask(QThread):
16
41
  """
17
42
  This class is responsible for preparing the package for the oQtopus module management tool.
18
43
  It inherits from QThread to run the preparation process in a separate thread.
19
44
  """
20
45
 
21
- signalPackagingProgress = pyqtSignal(float)
46
+ signalPackagingProgress = pyqtSignal(float, int) # progress_percent, bytes_downloaded
22
47
 
23
48
  def __init__(self, parent=None):
24
49
  super().__init__(parent)
@@ -31,6 +56,11 @@ class PackagePrepareTask(QThread):
31
56
  self.__canceled = False
32
57
  self.lastError = None
33
58
 
59
+ # Track download progress across all assets
60
+ self.__download_total_expected = 0
61
+ self.__download_total_received = 0
62
+ self.__last_emitted_percent = None
63
+
34
64
  def startFromZip(self, module_package, zip_file: str):
35
65
  self.module_package = module_package
36
66
  self.from_zip_file = zip_file
@@ -60,6 +90,11 @@ class PackagePrepareTask(QThread):
60
90
  self.__destination_directory = self.__prepare_destination_directory()
61
91
  logger.info(f"Destination directory: {self.__destination_directory}")
62
92
 
93
+ # Reset progress tracking
94
+ self.__download_total_expected = 0
95
+ self.__download_total_received = 0
96
+ self.__last_emitted_percent = None
97
+
63
98
  self.__prepare_module_assets(self.module_package)
64
99
  self.lastError = None
65
100
 
@@ -71,24 +106,30 @@ class PackagePrepareTask(QThread):
71
106
  def __prepare_destination_directory(self):
72
107
  """
73
108
  Prepare the destination directory for the module package.
74
- This method creates a temporary directory for the package.
109
+ This method creates a cache directory for the package downloads.
75
110
  """
76
- temp_dir = PluginUtils.plugin_temp_path()
77
- destination_directory = os.path.join(
78
- temp_dir,
111
+ # Sanitize the package name to avoid filesystem issues (especially on Windows)
112
+ # PR names can contain special characters like # and spaces
113
+ safe_name = sanitize_filename(self.module_package.name)
114
+ cache_dir = os.path.join(
115
+ PluginUtils.plugin_cache_path(),
116
+ "pkgs",
79
117
  self.module_package.organisation,
80
118
  self.module_package.repository,
81
- self.module_package.name,
119
+ safe_name,
82
120
  )
83
- os.makedirs(destination_directory, exist_ok=True)
121
+ os.makedirs(cache_dir, exist_ok=True)
84
122
 
85
- return destination_directory
123
+ return cache_dir
86
124
 
87
125
  def __prepare_module_assets(self, module_package):
88
126
 
127
+ # Pre-fetch all file sizes to calculate accurate total progress
128
+ self.__prefetch_download_sizes(module_package)
129
+
89
130
  # Download the source or use from zip
90
131
  zip_file = self.from_zip_file or self.__download_module_asset(
91
- module_package.download_url, "source.zip"
132
+ module_package.download_url, "source.zip", module_package
92
133
  )
93
134
 
94
135
  module_package.source_package_zip = zip_file
@@ -101,6 +142,7 @@ class PackagePrepareTask(QThread):
101
142
  zip_file = self.__download_module_asset(
102
143
  module_package.asset_project.download_url,
103
144
  module_package.asset_project.type.value + ".zip",
145
+ module_package,
104
146
  )
105
147
  package_dir = self.__extract_zip_file(zip_file)
106
148
  module_package.asset_project.package_zip = zip_file
@@ -111,52 +153,225 @@ class PackagePrepareTask(QThread):
111
153
  zip_file = self.__download_module_asset(
112
154
  module_package.asset_plugin.download_url,
113
155
  module_package.asset_plugin.type.value + ".zip",
156
+ module_package,
114
157
  )
115
158
  package_dir = self.__extract_zip_file(zip_file)
116
159
  module_package.asset_plugin.package_zip = zip_file
117
160
  module_package.asset_plugin.package_dir = package_dir
118
161
 
119
- def __download_module_asset(self, url: str, filename: str):
162
+ def __prefetch_download_sizes(self, module_package):
163
+ """Pre-fetch Content-Length for all files to be downloaded for accurate progress."""
164
+ urls_to_check = []
120
165
 
121
- zip_file = os.path.join(self.__destination_directory, filename)
166
+ # Check source if not from zip
167
+ if self.from_zip_file is None:
168
+ # Only check if not already cached
169
+ cache_filename = self.__get_cache_filename("source.zip", module_package)
170
+ zip_file = os.path.join(self.__destination_directory, cache_filename)
171
+ if not self.__is_cached_and_valid(zip_file):
172
+ urls_to_check.append((module_package.download_url, "source.zip"))
173
+
174
+ # Check project asset
175
+ if module_package.asset_project is not None:
176
+ cache_filename = self.__get_cache_filename(
177
+ module_package.asset_project.type.value + ".zip", module_package
178
+ )
179
+ zip_file = os.path.join(self.__destination_directory, cache_filename)
180
+ if not self.__is_cached_and_valid(zip_file):
181
+ urls_to_check.append(
182
+ (
183
+ module_package.asset_project.download_url,
184
+ module_package.asset_project.type.value + ".zip",
185
+ )
186
+ )
187
+
188
+ # Check plugin asset
189
+ if module_package.asset_plugin is not None:
190
+ cache_filename = self.__get_cache_filename(
191
+ module_package.asset_plugin.type.value + ".zip", module_package
192
+ )
193
+ zip_file = os.path.join(self.__destination_directory, cache_filename)
194
+ if not self.__is_cached_and_valid(zip_file):
195
+ urls_to_check.append(
196
+ (
197
+ module_package.asset_plugin.download_url,
198
+ module_package.asset_plugin.type.value + ".zip",
199
+ )
200
+ )
201
+
202
+ # If everything is cached, skip size checking entirely
203
+ if not urls_to_check:
204
+ self.__download_total_expected = 0
205
+ return
206
+
207
+ total_size = 0
208
+ for url, filename in urls_to_check:
209
+ # Get Content-Length via HEAD request
210
+ try:
211
+ response = requests.head(url, allow_redirects=True, timeout=10)
212
+ content_length = response.headers.get("content-length")
213
+ if content_length:
214
+ file_size = int(content_length)
215
+ total_size += file_size
216
+ else:
217
+ logger.warning(
218
+ f"No Content-Length for '{filename}', progress may be inaccurate"
219
+ )
220
+ except Exception as e:
221
+ logger.warning(f"Failed to get size for '{filename}': {e}")
222
+
223
+ self.__download_total_expected = total_size
224
+ if total_size > 0:
225
+ logger.info(f"Total download size: {total_size / (1024 * 1024):.1f} MB")
226
+
227
+ def __is_cached_and_valid(self, zip_file):
228
+ """Check if a zip file is cached and valid (quick check)."""
229
+ if not os.path.exists(zip_file):
230
+ return False
231
+ # Quick check: file exists and has reasonable size
232
+ try:
233
+ size = os.path.getsize(zip_file)
234
+ if size < 100: # Too small to be valid
235
+ return False
236
+ # Just check if it opens as a valid zip, don't read entire contents
237
+ with zipfile.ZipFile(zip_file, "r") as zip_test:
238
+ # Quick check - just verify we can read the file list
239
+ _ = zip_test.namelist()
240
+ return True
241
+ except (zipfile.BadZipFile, OSError, Exception):
242
+ return False
243
+
244
+ def __get_cache_filename(self, base_filename: str, module_package):
245
+ """Generate cache filename, including commit SHA for branches/PRs if available."""
246
+ if module_package.type in (ModulePackage.Type.BRANCH, ModulePackage.Type.PULL_REQUEST):
247
+ if module_package.commit_sha:
248
+ # Include commit SHA in filename for cache invalidation
249
+ name, ext = os.path.splitext(base_filename)
250
+ return f"{name}-{module_package.commit_sha[:8]}{ext}"
251
+ else:
252
+ # No commit SHA available, don't cache (use unique name)
253
+ import time
254
+
255
+ name, ext = os.path.splitext(base_filename)
256
+ return f"{name}-{int(time.time())}{ext}"
257
+ return base_filename
258
+
259
+ def __download_module_asset(self, url: str, filename: str, module_package):
260
+
261
+ cache_filename = self.__get_cache_filename(filename, module_package)
262
+ zip_file = os.path.join(self.__destination_directory, cache_filename)
263
+
264
+ # Check if file already exists and is valid
265
+ if os.path.exists(zip_file):
266
+ try:
267
+ # Quick validation - just check if it's a valid zip structure
268
+ file_size = os.path.getsize(zip_file)
269
+ if file_size > 100: # Has reasonable size
270
+ with zipfile.ZipFile(zip_file, "r") as zip_test:
271
+ # Just verify we can read file list, don't test entire contents
272
+ _ = zip_test.namelist()
273
+ logger.info(f"Using cached: {os.path.basename(zip_file)}")
274
+ # Still emit some progress to show we're not stuck
275
+ self.signalPackagingProgress.emit(-1.0, 0)
276
+ return zip_file
277
+ except (zipfile.BadZipFile, OSError, Exception) as e:
278
+ logger.warning(f"Existing file '{zip_file}' is invalid ({e}), will re-download")
279
+ try:
280
+ os.remove(zip_file)
281
+ except OSError:
282
+ pass
122
283
 
123
284
  # Streaming, so we can iterate over the response.
124
- response = requests.get(url, allow_redirects=True, stream=True)
285
+ timeout = 60
286
+ logger.info(f"Downloading: {os.path.basename(zip_file)}")
287
+
288
+ try:
289
+ response = requests.get(url, allow_redirects=True, stream=True, timeout=timeout)
290
+ except Exception as e:
291
+ logger.error(f"HTTP request failed: {e}")
292
+ raise
125
293
 
126
294
  # Raise an exception in case of http errors
127
295
  response.raise_for_status()
128
296
 
129
297
  self.__checkForCanceled()
130
298
 
131
- logger.info(f"Downloading from '{url}' to '{zip_file}'")
132
- data_size = 0
299
+ # Get total file size from headers
300
+ content_length = response.headers.get("content-length")
301
+ file_size = int(content_length) if content_length else 0
302
+
303
+ if file_size == 0:
304
+ # Emit indeterminate progress
305
+ self.signalPackagingProgress.emit(-1.0, 0)
306
+
307
+ downloaded_size = 0
133
308
  with open(zip_file, "wb") as file:
134
- next_emit_threshold = 10 * 1024 * 1024 # 10MB threshold
135
- for data in response.iter_content(chunk_size=None):
309
+ chunk_size = 256 * 1024 # 256KB chunks
310
+ for data in response.iter_content(chunk_size=chunk_size, decode_unicode=False):
136
311
  file.write(data)
137
312
 
138
313
  self.__checkForCanceled()
139
314
 
140
- data_size += len(data)
141
- if data_size >= next_emit_threshold: # Emit signal when threshold is exceeded
142
- self.signalPackagingProgress.emit(data_size)
143
- next_emit_threshold += 10 * 1024 * 1024 # Update to the next threshold
315
+ chunk_len = len(data)
316
+ downloaded_size += chunk_len
317
+ self.__download_total_received += chunk_len
318
+
319
+ # Emit progress on percentage change
320
+ self.__emit_progress()
321
+
322
+ # Ensure final progress reflects completion
323
+ self.__emit_progress(force=True)
144
324
 
145
325
  return zip_file
146
326
 
327
+ def __emit_progress(self, force: bool = False):
328
+ """Emit download progress as percentage (0-100) or -1 for indeterminate."""
329
+ if self.__download_total_expected <= 0:
330
+ # Size unknown, emit indeterminate progress with bytes downloaded
331
+ self.signalPackagingProgress.emit(-1.0, self.__download_total_received)
332
+ return
333
+
334
+ percent = int((self.__download_total_received * 100) / self.__download_total_expected)
335
+ percent = max(0, min(100, percent)) # Clamp to 0-100
336
+
337
+ if force or self.__last_emitted_percent != percent:
338
+ self.__last_emitted_percent = percent
339
+ self.signalPackagingProgress.emit(float(percent), self.__download_total_received)
340
+
147
341
  def __extract_zip_file(self, zip_file):
148
342
  # Unzip the file to plugin temp dir
343
+ # Don't set indeterminate here - it confuses the progress when downloading multiple files
344
+
149
345
  try:
150
346
  with zipfile.ZipFile(zip_file, "r") as zip_ref:
151
- # Find the top-level directory
347
+ # Find the top-level directory in the zip
152
348
  zip_dirname = zip_ref.namelist()[0].split("/")[0]
153
- package_dir = os.path.join(self.__destination_directory, zip_dirname)
154
-
155
- if os.path.exists(package_dir):
156
- shutil.rmtree(package_dir)
157
349
 
350
+ # Use short "src" name to avoid Windows MAX_PATH issues
351
+ package_dir = os.path.join(self.__destination_directory, "src")
352
+
353
+ # Check if already extracted and valid
354
+ if os.path.exists(package_dir) and os.path.isdir(package_dir):
355
+ # Verify it's not empty and has some expected content
356
+ if os.listdir(package_dir):
357
+ logger.info(
358
+ f"Directory '{package_dir}' already extracted - skipping extraction"
359
+ )
360
+ return package_dir
361
+ else:
362
+ logger.warning(f"Directory '{package_dir}' is empty, will re-extract")
363
+ shutil.rmtree(package_dir)
364
+
365
+ logger.info(f"Extracting '{zip_file}'...")
158
366
  zip_ref.extractall(self.__destination_directory)
159
367
 
368
+ # Rename extracted dir to "src" to shorten paths
369
+ extracted_dir = os.path.join(self.__destination_directory, zip_dirname)
370
+ if extracted_dir != package_dir:
371
+ shutil.move(extracted_dir, package_dir)
372
+
373
+ logger.info(f"Extraction complete: '{package_dir}'")
374
+
160
375
  except zipfile.BadZipFile:
161
376
  raise Exception(self.tr(f"The selected file '{zip_file}' is not a valid zip archive."))
162
377
 
@@ -1,11 +1,11 @@
1
1
  import psycopg
2
- from pgserviceparser import conf_path as pgserviceparser_conf_path
3
- from pgserviceparser import service_config as pgserviceparser_service_config
4
- from pgserviceparser import service_names as pgserviceparser_service_names
5
2
  from qgis.PyQt.QtCore import pyqtSignal
6
3
  from qgis.PyQt.QtGui import QAction
7
4
  from qgis.PyQt.QtWidgets import QDialog, QMenu, QWidget
8
5
 
6
+ from ..libs.pgserviceparser import conf_path as pgserviceparser_conf_path
7
+ from ..libs.pgserviceparser import service_config as pgserviceparser_service_config
8
+ from ..libs.pgserviceparser import service_names as pgserviceparser_service_names
9
9
  from ..utils.plugin_utils import PluginUtils, logger
10
10
  from ..utils.qt_utils import CriticalMessageBox, QtUtils
11
11
  from .database_create_dialog import DatabaseCreateDialog
@@ -78,8 +78,14 @@ class DatabaseConnectionWidget(QWidget, DIALOG_UI):
78
78
  self.db_services_comboBox.clear()
79
79
 
80
80
  try:
81
+ self.db_services_comboBox.addItem(self.tr("Please select a service"), None)
82
+ # Disable the placeholder item
83
+ model = self.db_services_comboBox.model()
84
+ item = model.item(0)
85
+ item.setEnabled(False)
86
+
81
87
  for service_name in pgserviceparser_service_names():
82
- self.db_services_comboBox.addItem(service_name)
88
+ self.db_services_comboBox.addItem(service_name, service_name)
83
89
  except Exception as exception:
84
90
  CriticalMessageBox(
85
91
  self.tr("Error"), self.tr("Can't load database services:"), exception, self
@@ -87,7 +93,8 @@ class DatabaseConnectionWidget(QWidget, DIALOG_UI):
87
93
  return
88
94
 
89
95
  def __serviceChanged(self, index=None):
90
- if self.db_services_comboBox.currentText() == "":
96
+ # Check if placeholder is selected (currentData is None)
97
+ if self.db_services_comboBox.currentData() is None:
91
98
  self.db_database_label.setText(self.tr("No database"))
92
99
  QtUtils.setForegroundColor(self.db_database_label, PluginUtils.COLOR_WARNING)
93
100
  QtUtils.setFontItalic(self.db_database_label, True)
@@ -23,12 +23,12 @@
23
23
  # ---------------------------------------------------------------------
24
24
 
25
25
  import psycopg
26
- from pgserviceparser import service_config as pgserviceparser_service_config
27
- from pgserviceparser import service_names as pgserviceparser_service_names
28
- from pgserviceparser import write_service as pgserviceparser_write_service
29
26
  from qgis.PyQt.QtCore import Qt
30
27
  from qgis.PyQt.QtWidgets import QDialog, QMessageBox
31
28
 
29
+ from ..libs.pgserviceparser import service_config as pgserviceparser_service_config
30
+ from ..libs.pgserviceparser import service_names as pgserviceparser_service_names
31
+ from ..libs.pgserviceparser import write_service as pgserviceparser_write_service
32
32
  from ..utils.plugin_utils import PluginUtils, logger
33
33
  from ..utils.qt_utils import OverrideCursor
34
34
 
@@ -23,12 +23,12 @@
23
23
  # ---------------------------------------------------------------------
24
24
 
25
25
  import psycopg
26
- from pgserviceparser import full_config as pgserviceparser_full_config
27
- from pgserviceparser import service_config as pgserviceparser_service_config
28
- from pgserviceparser import write_service as pgserviceparser_write_service
29
26
  from qgis.PyQt.QtCore import Qt
30
27
  from qgis.PyQt.QtWidgets import QDialog, QMessageBox
31
28
 
29
+ from ..libs.pgserviceparser import full_config as pgserviceparser_full_config
30
+ from ..libs.pgserviceparser import service_config as pgserviceparser_service_config
31
+ from ..libs.pgserviceparser import write_service as pgserviceparser_write_service
32
32
  from ..utils.plugin_utils import PluginUtils, logger
33
33
  from ..utils.qt_utils import OverrideCursor
34
34
 
@@ -90,7 +90,7 @@ class DatabaseDuplicateDialog(QDialog, DIALOG_UI):
90
90
  with OverrideCursor(Qt.CursorShape.WaitCursor):
91
91
  with database_connection.cursor() as cursor:
92
92
  cursor.execute(
93
- f"CREATE DATABASE {new_database_name} TEMPLATE {self.__existing_service_config.get('dbname')}"
93
+ f'CREATE DATABASE "{new_database_name}" TEMPLATE "{self.__existing_service_config.get("dbname")}"'
94
94
  )
95
95
  except psycopg.Error as e:
96
96
  errorText = self.tr(f"Error duplicating database:\n{e}.")
@@ -1,13 +1,25 @@
1
1
  import logging
2
+ from datetime import datetime
2
3
 
3
4
  from qgis.PyQt.QtCore import QAbstractItemModel, QModelIndex, QSortFilterProxyModel, Qt
4
- from qgis.PyQt.QtWidgets import QAbstractItemView, QApplication, QStyle, QWidget
5
-
5
+ from qgis.PyQt.QtGui import QKeySequence
6
+ from qgis.PyQt.QtWidgets import (
7
+ QAbstractItemView,
8
+ QApplication,
9
+ QShortcut,
10
+ QStyle,
11
+ QWidget,
12
+ )
13
+
14
+ # Import and register SQL logging level from pum
15
+ from ..libs.pum import SQL
6
16
  from ..utils.plugin_utils import LoggingBridge, PluginUtils
7
17
 
8
- DIALOG_UI = PluginUtils.get_ui_class("logs_widget.ui")
18
+ logging.addLevelName(SQL, "SQL")
9
19
 
10
20
 
21
+ DIALOG_UI = PluginUtils.get_ui_class("logs_widget.ui")
22
+
11
23
  COLUMNS = ["Level", "Module", "Message"]
12
24
 
13
25
 
@@ -33,7 +45,7 @@ class LogModel(QAbstractItemModel):
33
45
  return len(COLUMNS)
34
46
 
35
47
  def data(self, index: QModelIndex, role: Qt.ItemDataRole = None):
36
- if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
48
+ if not index.isValid():
37
49
  return None
38
50
  if (
39
51
  index.row() < 0
@@ -42,8 +54,18 @@ class LogModel(QAbstractItemModel):
42
54
  or index.column() >= len(COLUMNS)
43
55
  ):
44
56
  return None
57
+
45
58
  log = self.logs[index.row()]
46
- return log[COLUMNS[index.column()]]
59
+ col_name = COLUMNS[index.column()]
60
+ value = log[col_name]
61
+
62
+ if role == Qt.ItemDataRole.DisplayRole:
63
+ return value
64
+ elif role == Qt.ItemDataRole.ToolTipRole:
65
+ # Show full text in tooltip, especially useful for long messages
66
+ if col_name == "Message":
67
+ return value
68
+ return None
47
69
 
48
70
  def index(self, row: int, column: int, parent=None):
49
71
  if row < 0 or row >= len(self.logs) or column < 0 or column >= len(COLUMNS):
@@ -69,7 +91,7 @@ class LogModel(QAbstractItemModel):
69
91
 
70
92
 
71
93
  class LogFilterProxyModel(QSortFilterProxyModel):
72
- LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
94
+ LEVELS = ["SQL", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
73
95
 
74
96
  def __init__(self, parent=None):
75
97
  super().__init__(parent)
@@ -123,13 +145,30 @@ class LogsWidget(QWidget, DIALOG_UI):
123
145
  self.logs_treeView.setModel(self.proxy_model)
124
146
  self.logs_treeView.setAlternatingRowColors(True)
125
147
  self.logs_treeView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
126
- self.logs_treeView.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
148
+ self.logs_treeView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
127
149
  self.logs_treeView.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
150
+
151
+ # Enable word wrapping for better readability
152
+ self.logs_treeView.setWordWrap(True)
153
+ self.logs_treeView.setTextElideMode(Qt.TextElideMode.ElideNone)
154
+
155
+ # Configure column widths
156
+ header = self.logs_treeView.header()
157
+ header.setStretchLastSection(True) # Message column stretches to fill space
158
+ header.resizeSection(0, 100) # Level column - fixed width
159
+ header.resizeSection(1, 150) # Module column - fixed width
160
+ # Message column will take remaining space due to setStretchLastSection
161
+
162
+ # Enable automatic row height adjustment
163
+ self.logs_treeView.setUniformRowHeights(False)
164
+ header.setDefaultSectionSize(100)
165
+
128
166
  self.loggingBridge.loggedLine.connect(self.__logged_line)
129
167
  logging.getLogger().addHandler(self.loggingBridge)
130
168
 
131
169
  self.logs_level_comboBox.addItems(
132
170
  [
171
+ "SQL",
133
172
  "DEBUG",
134
173
  "INFO",
135
174
  "WARNING",
@@ -154,13 +193,20 @@ class LogsWidget(QWidget, DIALOG_UI):
154
193
  self.logs_clear_toolButton.clicked.connect(self.__logsClearClicked)
155
194
  self.logs_filter_LineEdit.textChanged.connect(self.proxy_model.setFilterFixedString)
156
195
 
196
+ # Add copy shortcut (Ctrl+C)
197
+ self.copy_shortcut = QShortcut(QKeySequence.Copy, self.logs_treeView)
198
+ self.copy_shortcut.activated.connect(self.__copySelectedRows)
199
+
157
200
  def close(self):
158
201
  # uninstall the logging bridge
159
202
  logging.getLogger().removeHandler(self.loggingBridge)
160
203
 
161
204
  def __logged_line(self, record, line):
205
+ # Convert timestamp from record.created (epoch time) to readable format
206
+ timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
162
207
 
163
208
  log_entry = {
209
+ "Timestamp": timestamp,
164
210
  "Level": record.levelname,
165
211
  "Module": record.name,
166
212
  "Message": record.msg,
@@ -180,3 +226,44 @@ class LogsWidget(QWidget, DIALOG_UI):
180
226
 
181
227
  def __logsClearClicked(self):
182
228
  self.logs_model.clear()
229
+
230
+ def __copySelectedRows(self):
231
+ """Copy selected rows to clipboard in CSV format."""
232
+ selection_model = self.logs_treeView.selectionModel()
233
+ selected_indexes = selection_model.selectedRows()
234
+
235
+ if not selected_indexes:
236
+ return
237
+
238
+ # Sort by row number to maintain order
239
+ selected_indexes.sort(key=lambda idx: idx.row())
240
+
241
+ # Build CSV content with header
242
+ csv_lines = ["Timestamp,Level,Module,Message"]
243
+
244
+ for proxy_index in selected_indexes:
245
+ # Map proxy index to source model index
246
+ source_index = self.proxy_model.mapToSource(proxy_index)
247
+ row = source_index.row()
248
+ log_entry = self.logs_model.logs[row]
249
+
250
+ # Escape fields that might contain commas or quotes
251
+ def escape_csv(value):
252
+ value = str(value)
253
+ if "," in value or '"' in value or "\n" in value:
254
+ return '"' + value.replace('"', '""') + '"'
255
+ return value
256
+
257
+ csv_line = ",".join(
258
+ [
259
+ escape_csv(log_entry["Timestamp"]),
260
+ escape_csv(log_entry["Level"]),
261
+ escape_csv(log_entry["Module"]),
262
+ escape_csv(log_entry["Message"]),
263
+ ]
264
+ )
265
+ csv_lines.append(csv_line)
266
+
267
+ # Copy to clipboard
268
+ clipboard = QApplication.clipboard()
269
+ clipboard.setText("\n".join(csv_lines))