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

Potentially problematic release.


This version of ankigammon might be problematic. Click here for more details.

Files changed (56) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +373 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +224 -0
  7. ankigammon/anki/apkg_exporter.py +123 -0
  8. ankigammon/anki/card_generator.py +1307 -0
  9. ankigammon/anki/card_styles.py +1034 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +209 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +597 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +163 -0
  15. ankigammon/gui/dialogs/input_dialog.py +776 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +384 -0
  18. ankigammon/gui/format_detector.py +292 -0
  19. ankigammon/gui/main_window.py +1071 -0
  20. ankigammon/gui/resources/icon.icns +0 -0
  21. ankigammon/gui/resources/icon.ico +0 -0
  22. ankigammon/gui/resources/icon.png +0 -0
  23. ankigammon/gui/resources/style.qss +394 -0
  24. ankigammon/gui/resources.py +26 -0
  25. ankigammon/gui/widgets/__init__.py +8 -0
  26. ankigammon/gui/widgets/position_list.py +193 -0
  27. ankigammon/gui/widgets/smart_input.py +268 -0
  28. ankigammon/models.py +322 -0
  29. ankigammon/parsers/__init__.py +7 -0
  30. ankigammon/parsers/gnubg_parser.py +454 -0
  31. ankigammon/parsers/xg_binary_parser.py +870 -0
  32. ankigammon/parsers/xg_text_parser.py +729 -0
  33. ankigammon/renderer/__init__.py +5 -0
  34. ankigammon/renderer/animation_controller.py +406 -0
  35. ankigammon/renderer/animation_helper.py +221 -0
  36. ankigammon/renderer/color_schemes.py +145 -0
  37. ankigammon/renderer/svg_board_renderer.py +824 -0
  38. ankigammon/settings.py +239 -0
  39. ankigammon/thirdparty/__init__.py +7 -0
  40. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  41. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  42. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  43. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  44. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  45. ankigammon/utils/__init__.py +13 -0
  46. ankigammon/utils/gnubg_analyzer.py +431 -0
  47. ankigammon/utils/gnuid.py +622 -0
  48. ankigammon/utils/move_parser.py +239 -0
  49. ankigammon/utils/ogid.py +335 -0
  50. ankigammon/utils/xgid.py +419 -0
  51. ankigammon-1.0.0.dist-info/METADATA +370 -0
  52. ankigammon-1.0.0.dist-info/RECORD +56 -0
  53. ankigammon-1.0.0.dist-info/WHEEL +5 -0
  54. ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
  55. ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
  56. ankigammon-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,597 @@
1
+ """
2
+ Export progress dialog with AnkiConnect/APKG support.
3
+ """
4
+
5
+ from typing import List
6
+ from pathlib import Path
7
+ from PySide6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QLabel, QProgressBar,
9
+ QPushButton, QTextEdit, QDialogButtonBox, QFileDialog
10
+ )
11
+ from PySide6.QtCore import Qt, QThread, Signal, Slot
12
+
13
+ from ankigammon.models import Decision
14
+ from ankigammon.anki.ankiconnect import AnkiConnect
15
+ from ankigammon.anki.apkg_exporter import ApkgExporter
16
+ from ankigammon.anki.card_generator import CardGenerator
17
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
18
+ from ankigammon.renderer.color_schemes import SCHEMES
19
+ from ankigammon.settings import Settings
20
+ from ankigammon.utils.gnubg_analyzer import GNUBGAnalyzer
21
+ from ankigammon.parsers.gnubg_parser import GNUBGParser
22
+
23
+
24
+ class AnalysisWorker(QThread):
25
+ """
26
+ Background thread for GnuBG analysis of positions.
27
+
28
+ Signals:
29
+ progress(int, int): current, total
30
+ status_message(str): status update
31
+ finished(bool, str, List[Decision]): success, message, analyzed_decisions
32
+ """
33
+
34
+ progress = Signal(int, int)
35
+ status_message = Signal(str)
36
+ finished = Signal(bool, str, list)
37
+
38
+ def __init__(self, decisions: List[Decision], settings: Settings):
39
+ super().__init__()
40
+ self.decisions = decisions
41
+ self.settings = settings
42
+ self._cancelled = False
43
+
44
+ def cancel(self):
45
+ """Request cancellation of the analysis."""
46
+ self._cancelled = True
47
+
48
+ def run(self):
49
+ """Analyze positions with GnuBG in background (parallel processing)."""
50
+ try:
51
+ analyzer = GNUBGAnalyzer(
52
+ gnubg_path=self.settings.gnubg_path,
53
+ analysis_ply=self.settings.gnubg_analysis_ply
54
+ )
55
+
56
+ # Find positions that need analysis
57
+ positions_to_analyze = [(i, d) for i, d in enumerate(self.decisions) if not d.candidate_moves]
58
+ total = len(positions_to_analyze)
59
+
60
+ if total == 0:
61
+ self.finished.emit(True, "No analysis needed", self.decisions)
62
+ return
63
+
64
+ analyzed_decisions = list(self.decisions) # Copy list
65
+
66
+ # Prepare position IDs for batch analysis
67
+ position_ids = [d.xgid for _, d in positions_to_analyze]
68
+
69
+ # Progress callback for parallel analysis
70
+ def progress_callback(completed: int, total_positions: int):
71
+ if self._cancelled:
72
+ # Note: We can't easily cancel ProcessPoolExecutor mid-flight,
73
+ # but we'll check after analysis completes
74
+ return
75
+ self.progress.emit(completed, total_positions)
76
+ self.status_message.emit(
77
+ f"Analyzing position {completed} of {total_positions} with GnuBG ({self.settings.gnubg_analysis_ply}-ply)..."
78
+ )
79
+
80
+ # Analyze all positions in parallel
81
+ self.status_message.emit(
82
+ f"Starting analysis of {total} position(s) with GnuBG ({self.settings.gnubg_analysis_ply}-ply)..."
83
+ )
84
+ analysis_results = analyzer.analyze_positions_parallel(
85
+ position_ids,
86
+ progress_callback=progress_callback
87
+ )
88
+
89
+ # Check for cancellation after batch completes
90
+ if self._cancelled:
91
+ self.finished.emit(False, "Analysis cancelled by user", self.decisions)
92
+ return
93
+
94
+ # Parse results and update decisions
95
+ for idx, (pos_idx, decision) in enumerate(positions_to_analyze):
96
+ gnubg_output, decision_type = analysis_results[idx]
97
+
98
+ analyzed_decision = GNUBGParser.parse_analysis(
99
+ gnubg_output,
100
+ decision.xgid,
101
+ decision_type
102
+ )
103
+
104
+ # Preserve user-added metadata from original decision
105
+ analyzed_decision.note = decision.note
106
+ analyzed_decision.source_file = decision.source_file
107
+ analyzed_decision.game_number = decision.game_number
108
+ analyzed_decision.move_number = decision.move_number
109
+ analyzed_decision.position_image_path = decision.position_image_path
110
+
111
+ analyzed_decisions[pos_idx] = analyzed_decision
112
+
113
+ self.finished.emit(True, f"Analyzed {total} position(s)", analyzed_decisions)
114
+
115
+ except Exception as e:
116
+ self.finished.emit(False, f"Analysis failed: {str(e)}", self.decisions)
117
+
118
+
119
+ class ExportWorker(QThread):
120
+ """
121
+ Background thread for export operations.
122
+
123
+ Signals:
124
+ progress(float): progress as percentage (0.0 to 1.0)
125
+ status_message(str): status update
126
+ finished(bool, str): success, message
127
+ """
128
+
129
+ progress = Signal(float)
130
+ status_message = Signal(str)
131
+ finished = Signal(bool, str)
132
+
133
+ def __init__(
134
+ self,
135
+ decisions: List[Decision],
136
+ settings: Settings,
137
+ export_method: str,
138
+ output_path: str = None
139
+ ):
140
+ super().__init__()
141
+ self.decisions = decisions
142
+ self.settings = settings
143
+ self.export_method = export_method
144
+ self.output_path = output_path
145
+ self._cancelled = False
146
+
147
+ def cancel(self):
148
+ """Request cancellation of the export."""
149
+ self._cancelled = True
150
+
151
+ def run(self):
152
+ """Execute export in background thread."""
153
+ try:
154
+ if self.export_method == "ankiconnect":
155
+ self._export_ankiconnect()
156
+ else:
157
+ self._export_apkg()
158
+ except Exception as e:
159
+ self.finished.emit(False, f"Export failed: {str(e)}")
160
+
161
+ def _export_ankiconnect(self):
162
+ """Export via AnkiConnect."""
163
+ self.status_message.emit("Connecting to Anki...")
164
+ self.progress.emit(0.0)
165
+
166
+ # Test connection
167
+ client = AnkiConnect(deck_name=self.settings.deck_name)
168
+ if not client.test_connection():
169
+ self.finished.emit(False, "Could not connect to Anki. Is Anki running with AnkiConnect installed?")
170
+ return
171
+
172
+ # Create model and deck if needed
173
+ self.status_message.emit("Setting up Anki deck...")
174
+ try:
175
+ client.create_model()
176
+ client.create_deck()
177
+ except Exception as e:
178
+ self.finished.emit(False, f"Failed to setup Anki deck: {str(e)}")
179
+ return
180
+
181
+ # Generate cards
182
+ self.status_message.emit("Generating cards...")
183
+
184
+ # Create renderer with color scheme and orientation
185
+ color_scheme = SCHEMES.get(self.settings.color_scheme, SCHEMES['classic'])
186
+ renderer = SVGBoardRenderer(
187
+ color_scheme=color_scheme,
188
+ orientation=self.settings.board_orientation
189
+ )
190
+
191
+ # Export decisions
192
+ total = len(self.decisions)
193
+ for i, decision in enumerate(self.decisions):
194
+ # Check for cancellation
195
+ if self._cancelled:
196
+ self.finished.emit(False, "Export cancelled by user")
197
+ return
198
+
199
+ # Calculate base progress for this position
200
+ base_progress = i / total
201
+ position_progress_range = 1.0 / total # How much progress this position represents
202
+
203
+ # Estimate sub-steps (used for progress bar calculation)
204
+ # Steps: render positions (1), score matrix (N cells), generate card (1)
205
+ has_score_matrix = (
206
+ decision.decision_type.name == 'CUBE_ACTION' and
207
+ decision.match_length > 0 and
208
+ self.settings.get('generate_score_matrix', False) and
209
+ self.settings.is_gnubg_available()
210
+ )
211
+ matrix_steps = (decision.match_length - 1) ** 2 if has_score_matrix else 0
212
+ total_substeps = 2 + matrix_steps # render + matrix + generate card
213
+
214
+ current_substep = [0] # Use list for mutable counter in nested function
215
+
216
+ # Progress callback for sub-steps
217
+ def progress_callback(message: str):
218
+ current_substep[0] += 1
219
+ # Calculate progress within this position
220
+ substep_progress = min(current_substep[0] / total_substeps, 0.95) # Cap at 95% until Anki add
221
+ overall_progress = base_progress + (substep_progress * position_progress_range)
222
+ self.progress.emit(overall_progress)
223
+ self.status_message.emit(f"Position {i+1}/{total}: {message}")
224
+
225
+ # Create card generator with progress callback
226
+ output_dir = Path.home() / '.ankigammon' / 'cards'
227
+ card_gen = CardGenerator(
228
+ output_dir=output_dir,
229
+ show_options=self.settings.show_options,
230
+ interactive_moves=self.settings.interactive_moves,
231
+ renderer=renderer,
232
+ progress_callback=progress_callback
233
+ )
234
+
235
+ self.progress.emit(base_progress)
236
+
237
+ # Generate card (progress_callback will emit sub-steps and update progress)
238
+ card_data = card_gen.generate_card(decision)
239
+
240
+ # Add to Anki
241
+ self.status_message.emit(f"Position {i+1}/{total}: Adding to Anki...")
242
+ self.progress.emit(base_progress + (0.95 * position_progress_range))
243
+ try:
244
+ client.add_note(
245
+ front=card_data['front'],
246
+ back=card_data['back'],
247
+ tags=card_data.get('tags', [])
248
+ )
249
+ except Exception as e:
250
+ self.finished.emit(False, f"Failed to add card {i+1}: {str(e)}")
251
+ return
252
+
253
+ # Update progress after card is successfully added
254
+ self.progress.emit((i + 1) / total)
255
+
256
+ self.finished.emit(True, f"Successfully exported {total} card(s) to Anki")
257
+
258
+ def _export_apkg(self):
259
+ """Export to APKG file."""
260
+ self.status_message.emit("Generating APKG file...")
261
+ self.progress.emit(0.0)
262
+
263
+ if not self.output_path:
264
+ self.finished.emit(False, "No output path specified for APKG export")
265
+ return
266
+
267
+ try:
268
+ # Use existing APKG exporter
269
+ output_dir = Path.home() / '.ankigammon' / 'cards'
270
+ exporter = ApkgExporter(
271
+ output_dir=output_dir,
272
+ deck_name=self.settings.deck_name
273
+ )
274
+
275
+ # Custom export loop with progress tracking
276
+ from ankigammon.renderer.color_schemes import get_scheme
277
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
278
+ from ankigammon.anki.card_generator import CardGenerator
279
+ import genanki
280
+
281
+ scheme = get_scheme(self.settings.color_scheme)
282
+ renderer = SVGBoardRenderer(
283
+ color_scheme=scheme,
284
+ orientation=self.settings.board_orientation
285
+ )
286
+
287
+ # Generate cards
288
+ total = len(self.decisions)
289
+ for i, decision in enumerate(self.decisions):
290
+ # Check for cancellation
291
+ if self._cancelled:
292
+ self.finished.emit(False, "Export cancelled by user")
293
+ return
294
+
295
+ # Calculate base progress for this position
296
+ base_progress = i / total
297
+ position_progress_range = 1.0 / total
298
+
299
+ # Estimate sub-steps (same logic as AnkiConnect)
300
+ has_score_matrix = (
301
+ decision.decision_type.name == 'CUBE_ACTION' and
302
+ decision.match_length > 0 and
303
+ self.settings.get('generate_score_matrix', False) and
304
+ self.settings.is_gnubg_available()
305
+ )
306
+ matrix_steps = (decision.match_length - 1) ** 2 if has_score_matrix else 0
307
+ total_substeps = 2 + matrix_steps # render + matrix + generate card
308
+
309
+ current_substep = [0]
310
+
311
+ # Progress callback for sub-steps
312
+ def apkg_progress_callback(message: str):
313
+ current_substep[0] += 1
314
+ substep_progress = min(current_substep[0] / total_substeps, 0.95)
315
+ overall_progress = base_progress + (substep_progress * position_progress_range)
316
+ self.progress.emit(overall_progress)
317
+ self.status_message.emit(f"Position {i+1}/{total}: {message}")
318
+
319
+ # Create card generator with progress callback
320
+ card_gen = CardGenerator(
321
+ output_dir=output_dir,
322
+ show_options=self.settings.show_options,
323
+ interactive_moves=self.settings.interactive_moves,
324
+ renderer=renderer,
325
+ progress_callback=apkg_progress_callback
326
+ )
327
+
328
+ self.progress.emit(base_progress)
329
+
330
+ # Generate card
331
+ card_data = card_gen.generate_card(decision, card_id=f"card_{i}")
332
+
333
+ # Create note
334
+ note = genanki.Note(
335
+ model=exporter.model,
336
+ fields=[card_data['front'], card_data['back']],
337
+ tags=card_data['tags']
338
+ )
339
+
340
+ # Add to deck
341
+ exporter.deck.add_note(note)
342
+
343
+ # Update progress after card added
344
+ self.progress.emit((i + 1) / total)
345
+
346
+ # Write APKG file
347
+ self.status_message.emit("Writing APKG file...")
348
+ package = genanki.Package(exporter.deck)
349
+ package.write_to_file(str(self.output_path))
350
+
351
+ self.progress.emit(1.0)
352
+ self.finished.emit(True, f"Successfully created {self.output_path}")
353
+ except Exception as e:
354
+ self.finished.emit(False, f"APKG export failed: {str(e)}")
355
+
356
+
357
+ class ExportDialog(QDialog):
358
+ """Dialog for exporting positions to Anki."""
359
+
360
+ def __init__(
361
+ self,
362
+ decisions: List[Decision],
363
+ settings: Settings,
364
+ parent=None
365
+ ):
366
+ super().__init__(parent)
367
+ self.decisions = decisions
368
+ self.settings = settings
369
+ self.worker = None
370
+ self.analysis_worker = None
371
+ self._closing = False # Flag to track if user requested close
372
+
373
+ self.setWindowTitle("Export to Anki")
374
+ self.setModal(True)
375
+ self.setMinimumWidth(500)
376
+
377
+ self._setup_ui()
378
+
379
+ def _setup_ui(self):
380
+ """Initialize the user interface."""
381
+ layout = QVBoxLayout(self)
382
+
383
+ # Info label with deck name
384
+ info = QLabel(f"Exporting {len(self.decisions)} position(s)")
385
+ info.setStyleSheet("font-size: 13px; color: #a6adc8; margin-bottom: 4px;")
386
+ layout.addWidget(info)
387
+
388
+ # Deck name label (modern styling)
389
+ deck_label = QLabel(f"<span style='font-size: 16px; font-weight: 600; color: #cdd6f4;'>{self.settings.deck_name}</span>")
390
+ deck_label.setStyleSheet("padding: 12px 16px; background-color: rgba(137, 180, 250, 0.08); border-radius: 8px;")
391
+ layout.addWidget(deck_label)
392
+
393
+ # Progress bar (use percentage-based progress)
394
+ self.progress_bar = QProgressBar()
395
+ self.progress_bar.setRange(0, 100)
396
+ layout.addWidget(self.progress_bar)
397
+
398
+ # Status label
399
+ self.status_label = QLabel(f"Ready to export {len(self.decisions)} position(s) to deck {self.settings.deck_name}")
400
+ layout.addWidget(self.status_label)
401
+
402
+ # Log text (hidden initially)
403
+ self.log_text = QTextEdit()
404
+ self.log_text.setReadOnly(True)
405
+ self.log_text.setMaximumHeight(150)
406
+ self.log_text.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
407
+ self.log_text.hide()
408
+ layout.addWidget(self.log_text)
409
+
410
+ # Buttons
411
+ self.button_box = QDialogButtonBox()
412
+ self.btn_export = QPushButton("Export")
413
+ self.btn_export.setCursor(Qt.PointingHandCursor)
414
+ self.btn_export.clicked.connect(self.start_export)
415
+ self.btn_close = QPushButton("Close")
416
+ self.btn_close.setCursor(Qt.PointingHandCursor)
417
+ self.btn_close.clicked.connect(self.close_dialog)
418
+
419
+ self.button_box.addButton(self.btn_export, QDialogButtonBox.AcceptRole)
420
+ self.button_box.addButton(self.btn_close, QDialogButtonBox.RejectRole)
421
+ layout.addWidget(self.button_box)
422
+
423
+ def closeEvent(self, event):
424
+ """Handle window close event (X button, ESC key, etc)."""
425
+ # Use the same close logic
426
+ analysis_running = self.analysis_worker and self.analysis_worker.isRunning()
427
+ export_running = self.worker and self.worker.isRunning()
428
+
429
+ if analysis_running or export_running:
430
+ # Request cancellation and ignore this close event
431
+ self._closing = True
432
+ self.btn_close.setEnabled(False)
433
+
434
+ if analysis_running:
435
+ self.analysis_worker.cancel()
436
+ self.status_label.setText("Cancelling analysis...")
437
+ elif export_running:
438
+ self.worker.cancel()
439
+ self.status_label.setText("Cancelling export...")
440
+
441
+ event.ignore() # Don't close yet
442
+ return
443
+
444
+ # No workers running, allow close
445
+ event.accept()
446
+
447
+ @Slot()
448
+ def close_dialog(self):
449
+ """Handle close button click - cancel any running operations."""
450
+ # Check if any workers are running
451
+ analysis_running = self.analysis_worker and self.analysis_worker.isRunning()
452
+ export_running = self.worker and self.worker.isRunning()
453
+
454
+ if analysis_running or export_running:
455
+ # Set closing flag and request cancellation
456
+ self._closing = True
457
+ self.btn_close.setEnabled(False)
458
+
459
+ if analysis_running:
460
+ self.analysis_worker.cancel()
461
+ self.status_label.setText("Cancelling analysis...")
462
+ elif export_running:
463
+ self.worker.cancel()
464
+ self.status_label.setText("Cancelling export...")
465
+
466
+ # The finished signals will handle actually closing the dialog
467
+ return
468
+
469
+ # No workers running, close immediately
470
+ self.reject()
471
+
472
+ @Slot()
473
+ def start_export(self):
474
+ """Start export process in background thread."""
475
+ self.btn_export.setEnabled(False)
476
+
477
+ # Get output path for APKG if needed
478
+ self.output_path = None
479
+ if self.settings.export_method == "apkg":
480
+ # Use last directory if available, otherwise use home directory
481
+ if self.settings.last_apkg_directory:
482
+ default_path = Path(self.settings.last_apkg_directory) / f"{self.settings.deck_name}.apkg"
483
+ else:
484
+ default_path = Path.home() / f"{self.settings.deck_name}.apkg"
485
+
486
+ self.output_path, _ = QFileDialog.getSaveFileName(
487
+ self,
488
+ "Save APKG File",
489
+ str(default_path),
490
+ "Anki Deck Package (*.apkg)"
491
+ )
492
+ if not self.output_path:
493
+ self.btn_export.setEnabled(True)
494
+ return
495
+
496
+ # Save the directory for next time
497
+ self.settings.last_apkg_directory = str(Path(self.output_path).parent)
498
+
499
+ # Check if any positions need GnuBG analysis
500
+ needs_analysis = [d for d in self.decisions if not d.candidate_moves]
501
+
502
+ if needs_analysis:
503
+ # Run analysis first
504
+ self.status_label.setText(f"Analyzing {len(needs_analysis)} position(s) with GnuBG...")
505
+ self.analysis_worker = AnalysisWorker(self.decisions, self.settings)
506
+ self.analysis_worker.progress.connect(self.on_analysis_progress)
507
+ self.analysis_worker.status_message.connect(self.on_status_message)
508
+ self.analysis_worker.finished.connect(self.on_analysis_finished)
509
+ self.analysis_worker.start()
510
+ else:
511
+ # No analysis needed, proceed with export
512
+ self._start_export_worker()
513
+
514
+ def _start_export_worker(self):
515
+ """Start the actual export worker (after analysis if needed)."""
516
+ # Create worker thread
517
+ self.worker = ExportWorker(
518
+ self.decisions,
519
+ self.settings,
520
+ self.settings.export_method,
521
+ self.output_path
522
+ )
523
+
524
+ # Connect signals
525
+ self.worker.progress.connect(self.on_progress)
526
+ self.worker.status_message.connect(self.on_status_message)
527
+ self.worker.finished.connect(self.on_finished)
528
+
529
+ # Start export
530
+ self.worker.start()
531
+
532
+ @Slot(int, int)
533
+ def on_analysis_progress(self, current, total):
534
+ """Update progress bar for analysis (0-50% of total progress)."""
535
+ # Analysis takes first half of progress bar (0-50%)
536
+ self.progress_bar.setValue(int((current / total) * 50))
537
+
538
+ @Slot(bool, str, list)
539
+ def on_analysis_finished(self, success, message, analyzed_decisions):
540
+ """Handle analysis completion."""
541
+ # Check if user requested to close
542
+ if self._closing:
543
+ self.reject()
544
+ return
545
+
546
+ if success:
547
+ # Update decisions with analyzed versions
548
+ self.decisions = analyzed_decisions
549
+ self.status_label.setText(f"{message} - Starting export...")
550
+ # Proceed with export
551
+ self._start_export_worker()
552
+ else:
553
+ # Analysis failed
554
+ self.status_label.setText(f"Analysis failed: {message}")
555
+ self.log_text.append(f"ERROR: {message}")
556
+ self.btn_export.setEnabled(True)
557
+
558
+ @Slot(float)
559
+ def on_progress(self, progress_fraction):
560
+ """Update progress bar for export (50-100% of total progress).
561
+
562
+ Args:
563
+ progress_fraction: Progress as a fraction from 0.0 to 1.0
564
+ """
565
+ # Export takes second half of progress bar (50-100%)
566
+ # If no analysis was needed, this will go from 0-100% as expected
567
+ # If analysis was performed, this will go from 50-100%
568
+ if hasattr(self, 'analysis_worker') and self.analysis_worker is not None:
569
+ # Analysis was performed, map 0.0-1.0 to 50-100%
570
+ self.progress_bar.setValue(50 + int(progress_fraction * 50))
571
+ else:
572
+ # No analysis, map 0.0-1.0 to 0-100%
573
+ self.progress_bar.setValue(int(progress_fraction * 100))
574
+
575
+ @Slot(str)
576
+ def on_status_message(self, message):
577
+ """Update status label."""
578
+ self.status_label.setText(message)
579
+ self.log_text.append(message)
580
+ if self.log_text.isHidden():
581
+ self.log_text.show()
582
+
583
+ @Slot(bool, str)
584
+ def on_finished(self, success, message):
585
+ """Handle export completion."""
586
+ # Check if user requested to close
587
+ if self._closing:
588
+ self.reject()
589
+ return
590
+
591
+ self.status_label.setText(message)
592
+ self.log_text.append(f"\n{'SUCCESS' if success else 'FAILED'}: {message}")
593
+
594
+ if success:
595
+ self.btn_export.setEnabled(False)
596
+ else:
597
+ self.btn_export.setEnabled(True) # Allow retry