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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +373 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +224 -0
- ankigammon/anki/apkg_exporter.py +123 -0
- ankigammon/anki/card_generator.py +1307 -0
- ankigammon/anki/card_styles.py +1034 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +209 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +597 -0
- ankigammon/gui/dialogs/import_options_dialog.py +163 -0
- ankigammon/gui/dialogs/input_dialog.py +776 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +384 -0
- ankigammon/gui/format_detector.py +292 -0
- ankigammon/gui/main_window.py +1071 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +394 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +193 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +322 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_parser.py +454 -0
- ankigammon/parsers/xg_binary_parser.py +870 -0
- ankigammon/parsers/xg_text_parser.py +729 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +406 -0
- ankigammon/renderer/animation_helper.py +221 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +824 -0
- ankigammon/settings.py +239 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +431 -0
- ankigammon/utils/gnuid.py +622 -0
- ankigammon/utils/move_parser.py +239 -0
- ankigammon/utils/ogid.py +335 -0
- ankigammon/utils/xgid.py +419 -0
- ankigammon-1.0.0.dist-info/METADATA +370 -0
- ankigammon-1.0.0.dist-info/RECORD +56 -0
- ankigammon-1.0.0.dist-info/WHEEL +5 -0
- ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|