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,776 @@
1
+ """
2
+ Smart input dialog for adding positions via paste.
3
+
4
+ Supports:
5
+ - Position IDs (XGID/OGID/GNUID) - analyzed with GnuBG
6
+ - Full XG analysis text - parsed directly
7
+ """
8
+
9
+ from typing import List
10
+ from pathlib import Path
11
+
12
+ import qtawesome as qta
13
+
14
+ from PySide6.QtWidgets import (
15
+ QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
16
+ QLabel, QListWidget, QListWidgetItem, QMessageBox,
17
+ QFrame, QSplitter, QWidget, QProgressDialog, QMenu,
18
+ QAbstractItemView
19
+ )
20
+ from PySide6.QtCore import Qt, Signal, Slot
21
+ from PySide6.QtGui import QAction, QKeyEvent
22
+ from PySide6.QtWebEngineWidgets import QWebEngineView
23
+
24
+ from ankigammon.settings import Settings
25
+ from ankigammon.models import Decision, Position, Player, CubeState, DecisionType
26
+ from ankigammon.parsers.xg_text_parser import XGTextParser
27
+ from ankigammon.utils.gnuid import parse_gnuid
28
+ from ankigammon.utils.ogid import parse_ogid
29
+ from ankigammon.utils.xgid import parse_xgid
30
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
31
+ from ankigammon.renderer.color_schemes import get_scheme
32
+ from ankigammon.gui.format_detector import InputFormat
33
+ from ankigammon.gui.dialogs.note_dialog import NoteEditDialog
34
+
35
+
36
+ class PendingPositionItem(QListWidgetItem):
37
+ """List item for a pending position."""
38
+
39
+ def __init__(self, decision: Decision, needs_analysis: bool = False):
40
+ super().__init__()
41
+ self.decision = decision
42
+ self.needs_analysis = needs_analysis
43
+
44
+ # Set display text
45
+ self._update_display()
46
+
47
+ def _update_display(self):
48
+ """Update display text based on decision."""
49
+ # Use consistent display format
50
+ self.setText(self.decision.get_short_display_text())
51
+
52
+ # Icon based on status - use semantic colors
53
+ if self.needs_analysis:
54
+ self.setIcon(qta.icon('fa6s.magnifying-glass', color='#89b4fa')) # Info blue
55
+ else:
56
+ self.setIcon(qta.icon('fa6s.circle-check', color='#a6e3a1')) # Success green
57
+
58
+ # Tooltip with metadata + analysis status
59
+ tooltip = self.decision.get_metadata_text()
60
+ if self.needs_analysis:
61
+ tooltip += "\n\nNeeds GnuBG analysis"
62
+ else:
63
+ tooltip += f"\n\n{len(self.decision.candidate_moves)} moves analyzed"
64
+
65
+ # Add note if present
66
+ if self.decision.note:
67
+ tooltip += f"\n\nNote: {self.decision.note}"
68
+
69
+ self.setToolTip(tooltip)
70
+
71
+
72
+ class PendingListWidget(QListWidget):
73
+ """Custom list widget for pending positions with deletion support."""
74
+
75
+ items_deleted = Signal(list) # Emits list of indices of deleted items
76
+
77
+ def __init__(self, parent=None):
78
+ super().__init__(parent)
79
+
80
+ # Enable smooth scrolling
81
+ self.setVerticalScrollMode(QListWidget.ScrollPerPixel)
82
+
83
+ # Enable multi-selection (Ctrl+Click, Shift+Click)
84
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
85
+
86
+ # Enable context menu
87
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
88
+ self.customContextMenuRequested.connect(self._show_context_menu)
89
+
90
+ # Set styling
91
+ self.setStyleSheet("""
92
+ QListWidget {
93
+ background-color: #1e1e2e;
94
+ border: 2px solid #313244;
95
+ border-radius: 8px;
96
+ padding: 8px;
97
+ }
98
+ QListWidget::item {
99
+ padding: 8px;
100
+ border-radius: 4px;
101
+ color: #cdd6f4;
102
+ }
103
+ QListWidget::item:selected {
104
+ background-color: #45475a;
105
+ }
106
+ QListWidget::item:hover {
107
+ background-color: #313244;
108
+ }
109
+ """)
110
+
111
+ @Slot()
112
+ def _show_context_menu(self, pos):
113
+ """Show context menu for edit note and delete actions."""
114
+ # Get all selected items (not just the one at pos)
115
+ selected_items = self.selectedItems()
116
+
117
+ if not selected_items:
118
+ return
119
+
120
+ # Create context menu
121
+ menu = QMenu(self)
122
+ # Set cursor pointer for the menu
123
+ menu.setCursor(Qt.PointingHandCursor)
124
+
125
+ # Edit Note action (only for single selection)
126
+ if len(selected_items) == 1:
127
+ item = selected_items[0]
128
+ edit_note_action = QAction(
129
+ qta.icon('fa6s.note-sticky', color='#f9e2af'), # Yellow note icon
130
+ "Edit Note...",
131
+ self
132
+ )
133
+ edit_note_action.triggered.connect(lambda: self._edit_note(item))
134
+ menu.addAction(edit_note_action)
135
+
136
+ menu.addSeparator()
137
+
138
+ # Delete action (works for single or multiple)
139
+ delete_text = "Delete" if len(selected_items) == 1 else f"Delete {len(selected_items)} Items"
140
+ delete_action = QAction(
141
+ qta.icon('fa6s.trash', color='#f38ba8'), # Red delete icon
142
+ delete_text,
143
+ self
144
+ )
145
+ delete_action.triggered.connect(self._delete_selected_items)
146
+ menu.addAction(delete_action)
147
+
148
+ # Show menu at cursor position
149
+ menu.exec(self.mapToGlobal(pos))
150
+
151
+ def _edit_note(self, item: PendingPositionItem):
152
+ """Edit the note for a pending position."""
153
+ current_note = item.decision.note or ""
154
+
155
+ # Create custom note edit dialog
156
+ dialog = NoteEditDialog(current_note, "Note for pending position:", self)
157
+
158
+ # Show dialog and get result
159
+ if dialog.exec() == QDialog.Accepted:
160
+ new_note = dialog.get_text()
161
+
162
+ # Update the decision's note
163
+ item.decision.note = new_note.strip() if new_note.strip() else None
164
+
165
+ # Update tooltip to reflect the new note
166
+ tooltip = item.decision.get_short_display_text()
167
+ if item.needs_analysis:
168
+ tooltip += "\n\nNeeds GnuBG analysis"
169
+ else:
170
+ tooltip += f"\n\n{len(item.decision.candidate_moves)} moves analyzed"
171
+ if item.decision.note:
172
+ tooltip += f"\n\nNote: {item.decision.note}"
173
+ item.setToolTip(tooltip)
174
+
175
+ def _delete_selected_items(self):
176
+ """Delete all selected items from the list with confirmation."""
177
+ selected_items = self.selectedItems()
178
+
179
+ if not selected_items:
180
+ return
181
+
182
+ # Build confirmation message
183
+ if len(selected_items) == 1:
184
+ item = selected_items[0]
185
+ message = f"Delete pending position?\n\n{item.decision.get_short_display_text()}"
186
+ title = "Delete Position"
187
+ else:
188
+ message = f"Delete {len(selected_items)} selected pending position(s)?"
189
+ title = "Delete Positions"
190
+
191
+ # Confirm deletion
192
+ reply = QMessageBox.question(
193
+ self,
194
+ title,
195
+ message,
196
+ QMessageBox.Yes | QMessageBox.No
197
+ )
198
+
199
+ if reply == QMessageBox.Yes:
200
+ # Get row indices and sort in descending order
201
+ rows_to_delete = sorted([self.row(item) for item in selected_items], reverse=True)
202
+
203
+ # Delete from widget (highest to lowest to avoid index shifting)
204
+ for row in rows_to_delete:
205
+ self.takeItem(row)
206
+
207
+ # Emit single signal with all deleted row indices
208
+ self.items_deleted.emit(rows_to_delete)
209
+
210
+ def keyPressEvent(self, event: QKeyEvent):
211
+ """Handle keyboard events for deletion."""
212
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
213
+ # Support multi-selection deletion via keyboard
214
+ self._delete_selected_items()
215
+ else:
216
+ super().keyPressEvent(event)
217
+
218
+
219
+ class InputDialog(QDialog):
220
+ """
221
+ Dialog for smart position input.
222
+
223
+ Allows users to paste:
224
+ - Full XG analysis text (parsed directly)
225
+ - Position IDs (XGID/OGID/GNUID) - analyzed with GnuBG
226
+
227
+ Signals:
228
+ positions_added(List[Decision]): Emitted when positions are added
229
+ """
230
+
231
+ positions_added = Signal(list)
232
+
233
+ def __init__(self, settings: Settings, parent=None):
234
+ super().__init__(parent)
235
+ self.settings = settings
236
+ self.pending_decisions: List[Decision] = []
237
+ self.renderer = SVGBoardRenderer(
238
+ color_scheme=get_scheme(settings.color_scheme),
239
+ orientation=settings.board_orientation
240
+ )
241
+
242
+ self.setWindowTitle("Add Positions")
243
+ self.setModal(True)
244
+ self.setMinimumSize(1100, 850)
245
+ self.resize(1150, 900) # Default size - taller for better preview
246
+
247
+ self._setup_ui()
248
+ self._setup_connections()
249
+
250
+ def _setup_ui(self):
251
+ """Initialize the user interface."""
252
+ layout = QVBoxLayout(self)
253
+ layout.setContentsMargins(20, 20, 20, 20)
254
+ layout.setSpacing(16)
255
+
256
+ # Title
257
+ title = QLabel("<h2>Add Positions to Export List</h2>")
258
+ title.setStyleSheet("color: #f5e0dc; margin-bottom: 8px;")
259
+ layout.addWidget(title)
260
+
261
+ # Main content area (splitter)
262
+ splitter = QSplitter(Qt.Horizontal)
263
+
264
+ # Left side: Input area
265
+ left_widget = self._create_input_panel()
266
+ splitter.addWidget(left_widget)
267
+
268
+ # Right side: Pending list + preview
269
+ right_widget = self._create_pending_panel()
270
+ splitter.addWidget(right_widget)
271
+
272
+ # Set initial splitter ratio (50/50 balanced)
273
+ splitter.setStretchFactor(0, 50)
274
+ splitter.setStretchFactor(1, 50)
275
+
276
+ layout.addWidget(splitter, stretch=1)
277
+
278
+ # Bottom buttons
279
+ button_layout = QHBoxLayout()
280
+ button_layout.addStretch()
281
+
282
+ self.btn_done = QPushButton("Done")
283
+ self.btn_done.setStyleSheet("""
284
+ QPushButton {
285
+ background-color: #89b4fa;
286
+ color: #1e1e2e;
287
+ border: none;
288
+ padding: 10px 24px;
289
+ border-radius: 6px;
290
+ font-weight: 600;
291
+ font-size: 13px;
292
+ }
293
+ QPushButton:hover {
294
+ background-color: #a0c8fc;
295
+ }
296
+ QPushButton:pressed {
297
+ background-color: #74c7ec;
298
+ }
299
+ """)
300
+ self.btn_done.setCursor(Qt.PointingHandCursor)
301
+ self.btn_done.clicked.connect(self.accept)
302
+ button_layout.addWidget(self.btn_done)
303
+
304
+ self.btn_cancel = QPushButton("Cancel")
305
+ self.btn_cancel.setStyleSheet("""
306
+ QPushButton {
307
+ background-color: #45475a;
308
+ color: #cdd6f4;
309
+ border: none;
310
+ padding: 10px 24px;
311
+ border-radius: 6px;
312
+ font-size: 13px;
313
+ }
314
+ QPushButton:hover {
315
+ background-color: #585b70;
316
+ }
317
+ """)
318
+ self.btn_cancel.setCursor(Qt.PointingHandCursor)
319
+ self.btn_cancel.clicked.connect(self.reject)
320
+ button_layout.addWidget(self.btn_cancel)
321
+
322
+ layout.addLayout(button_layout)
323
+
324
+ def _create_input_panel(self) -> QWidget:
325
+ """Create the input panel with smart input widget."""
326
+ # Local import to avoid circular dependency
327
+ from ankigammon.gui.widgets import SmartInputWidget
328
+
329
+ panel = QWidget()
330
+ layout = QVBoxLayout(panel)
331
+ layout.setContentsMargins(0, 0, 0, 0)
332
+ layout.setSpacing(12)
333
+
334
+ # Smart input widget
335
+ self.input_widget = SmartInputWidget(self.settings)
336
+ layout.addWidget(self.input_widget, stretch=1)
337
+
338
+ # Buttons
339
+ button_layout = QHBoxLayout()
340
+
341
+ self.btn_add = QPushButton("Add to List")
342
+ self.btn_add.setStyleSheet("""
343
+ QPushButton {
344
+ background-color: #a6e3a1;
345
+ color: #1e1e2e;
346
+ border: none;
347
+ padding: 8px 20px;
348
+ border-radius: 6px;
349
+ font-weight: 600;
350
+ }
351
+ QPushButton:hover {
352
+ background-color: #94e2d5;
353
+ }
354
+ QPushButton:disabled {
355
+ background-color: #45475a;
356
+ color: #6c7086;
357
+ }
358
+ """)
359
+ self.btn_add.setCursor(Qt.PointingHandCursor)
360
+ self.btn_add.clicked.connect(self._on_add_clicked)
361
+ button_layout.addWidget(self.btn_add)
362
+
363
+ self.btn_clear = QPushButton("Clear Input")
364
+ self.btn_clear.setStyleSheet("""
365
+ QPushButton {
366
+ background-color: #45475a;
367
+ color: #cdd6f4;
368
+ border: none;
369
+ padding: 8px 20px;
370
+ border-radius: 6px;
371
+ }
372
+ QPushButton:hover {
373
+ background-color: #585b70;
374
+ }
375
+ """)
376
+ self.btn_clear.setCursor(Qt.PointingHandCursor)
377
+ self.btn_clear.clicked.connect(self.input_widget.clear_text)
378
+ button_layout.addWidget(self.btn_clear)
379
+
380
+ button_layout.addStretch()
381
+ layout.addLayout(button_layout)
382
+
383
+ return panel
384
+
385
+ def _create_pending_panel(self) -> QWidget:
386
+ """Create the pending positions panel."""
387
+ panel = QWidget()
388
+ layout = QVBoxLayout(panel)
389
+ layout.setContentsMargins(0, 0, 0, 0)
390
+ layout.setSpacing(12)
391
+
392
+ # Label with count
393
+ header_layout = QHBoxLayout()
394
+
395
+ label = QLabel("Pending Export:")
396
+ label.setStyleSheet("font-weight: 600; color: #cdd6f4;")
397
+ header_layout.addWidget(label)
398
+
399
+ self.count_label = QLabel("0 positions")
400
+ self.count_label.setStyleSheet("color: #a6adc8; font-size: 11px;")
401
+ header_layout.addWidget(self.count_label)
402
+
403
+ header_layout.addStretch()
404
+
405
+ self.btn_clear_all = QPushButton("Clear All")
406
+ self.btn_clear_all.setStyleSheet("""
407
+ QPushButton {
408
+ background-color: #45475a;
409
+ color: #cdd6f4;
410
+ border: none;
411
+ padding: 4px 12px;
412
+ border-radius: 4px;
413
+ font-size: 11px;
414
+ }
415
+ QPushButton:hover {
416
+ background-color: #f38ba8;
417
+ color: #1e1e2e;
418
+ }
419
+ """)
420
+ self.btn_clear_all.setCursor(Qt.PointingHandCursor)
421
+ self.btn_clear_all.clicked.connect(self._on_clear_all_clicked)
422
+ header_layout.addWidget(self.btn_clear_all)
423
+
424
+ layout.addLayout(header_layout)
425
+
426
+ # Create a vertical splitter between pending list and preview
427
+ splitter = QSplitter(Qt.Vertical)
428
+ splitter.setChildrenCollapsible(False) # Prevent collapsing sections
429
+
430
+ # Top section: Pending list
431
+ self.pending_list = PendingListWidget()
432
+ self.pending_list.currentItemChanged.connect(self._on_selection_changed)
433
+ self.pending_list.items_deleted.connect(self._on_items_deleted)
434
+ splitter.addWidget(self.pending_list)
435
+
436
+ # Bottom section: Preview pane
437
+ preview_container = QWidget()
438
+ preview_layout = QVBoxLayout(preview_container)
439
+ preview_layout.setContentsMargins(0, 8, 0, 0)
440
+ preview_layout.setSpacing(8)
441
+
442
+ preview_label = QLabel("Preview:")
443
+ preview_label.setStyleSheet("font-weight: 600; color: #cdd6f4;")
444
+ preview_layout.addWidget(preview_label)
445
+
446
+ self.preview = QWebEngineView()
447
+ self.preview.setContextMenuPolicy(Qt.NoContextMenu) # Disable browser context menu
448
+ self.preview.setMinimumHeight(400) # Increased for better board visibility
449
+ self.preview.setHtml(self._get_empty_preview_html())
450
+ preview_layout.addWidget(self.preview, stretch=1) # Allow preview to expand
451
+
452
+ splitter.addWidget(preview_container)
453
+
454
+ # Set initial splitter ratio (25% pending list, 75% preview)
455
+ splitter.setStretchFactor(0, 25)
456
+ splitter.setStretchFactor(1, 75)
457
+
458
+ layout.addWidget(splitter, stretch=1)
459
+
460
+ return panel
461
+
462
+ def _setup_connections(self):
463
+ """Setup signal connections."""
464
+ pass
465
+
466
+ @Slot()
467
+ def _on_add_clicked(self):
468
+ """Handle Add to List button click."""
469
+ text = self.input_widget.get_text()
470
+ result = self.input_widget.get_last_result()
471
+
472
+ if not text.strip():
473
+ QMessageBox.warning(
474
+ self,
475
+ "No Input",
476
+ "Please paste some text first"
477
+ )
478
+ return
479
+
480
+ if not result or result.format == InputFormat.UNKNOWN:
481
+ QMessageBox.warning(
482
+ self,
483
+ "Invalid Format",
484
+ "Could not detect valid position format.\n\n"
485
+ "Please paste XGID/OGID/GNUID or full XG analysis text."
486
+ )
487
+ return
488
+
489
+ # Check for GnuBG requirement
490
+ if result.format == InputFormat.POSITION_IDS and not self.settings.is_gnubg_available():
491
+ reply = QMessageBox.question(
492
+ self,
493
+ "GnuBG Required",
494
+ "Position IDs require GnuBG analysis, but GnuBG is not configured.\n\n"
495
+ "Would you like to configure GnuBG in Settings?",
496
+ QMessageBox.Yes | QMessageBox.No
497
+ )
498
+ if reply == QMessageBox.Yes:
499
+ # TODO: Open settings dialog
500
+ pass
501
+ return
502
+
503
+ # Parse the input
504
+ try:
505
+ decisions = self._parse_input(text, result.format)
506
+
507
+ if not decisions:
508
+ QMessageBox.warning(
509
+ self,
510
+ "Parse Failed",
511
+ "Could not parse any valid positions from input."
512
+ )
513
+ return
514
+
515
+ # Add to pending list
516
+ for decision in decisions:
517
+ # Check if decision actually has analysis (candidate_moves populated)
518
+ needs_analysis = not bool(decision.candidate_moves)
519
+ self.pending_decisions.append(decision)
520
+
521
+ item = PendingPositionItem(decision, needs_analysis)
522
+ self.pending_list.addItem(item)
523
+
524
+ # Update count
525
+ self._update_count_label()
526
+
527
+ # Clear input
528
+ self.input_widget.clear_text()
529
+
530
+ # Select first new item
531
+ if decisions:
532
+ self.pending_list.setCurrentRow(len(self.pending_decisions) - len(decisions))
533
+
534
+ except Exception as e:
535
+ QMessageBox.critical(
536
+ self,
537
+ "Parse Error",
538
+ f"Failed to parse input:\n{str(e)}"
539
+ )
540
+
541
+ def _parse_input(self, text: str, format_type: InputFormat) -> List[Decision]:
542
+ """Parse input text into Decision objects."""
543
+ if format_type == InputFormat.FULL_ANALYSIS:
544
+ # Use XGTextParser for full analysis
545
+ decisions = XGTextParser.parse_string(text)
546
+ return decisions
547
+
548
+ elif format_type == InputFormat.XG_BINARY:
549
+ # Binary format should use file import
550
+ raise ValueError(
551
+ "XG binary format (.xg files) must be imported via File → Import.\n"
552
+ "Binary data cannot be pasted as text."
553
+ )
554
+
555
+ elif format_type == InputFormat.POSITION_IDS:
556
+ # Try parsing as position IDs (XGID, GNUID, or OGID)
557
+ decisions = []
558
+
559
+ # Split by lines
560
+ lines = [line.strip() for line in text.split('\n') if line.strip()]
561
+
562
+ for line in lines:
563
+ decision = self._parse_position_id(line)
564
+ if decision:
565
+ decisions.append(decision)
566
+
567
+ return decisions
568
+
569
+ return []
570
+
571
+ def _parse_position_id(self, position_id: str) -> Decision:
572
+ """
573
+ Parse a single position ID (XGID, GNUID, or OGID) into a Decision.
574
+
575
+ Args:
576
+ position_id: Position ID string
577
+
578
+ Returns:
579
+ Decision object or None if parsing fails
580
+ """
581
+ # Try XGID first
582
+ if 'XGID=' in position_id or ':' in position_id:
583
+ try:
584
+ position, metadata = parse_xgid(position_id)
585
+ return self._create_decision_from_metadata(position, metadata)
586
+ except:
587
+ pass
588
+
589
+ # Try GNUID (14:12 Base64 format)
590
+ if ':' in position_id:
591
+ parts = position_id.split(':')
592
+ if len(parts) >= 2 and len(parts[0]) == 14 and len(parts[1]) == 12:
593
+ try:
594
+ position, metadata = parse_gnuid(position_id)
595
+ return self._create_decision_from_metadata(position, metadata)
596
+ except:
597
+ pass
598
+
599
+ # Try OGID (base-26 format)
600
+ if ':' in position_id:
601
+ try:
602
+ position, metadata = parse_ogid(position_id)
603
+ return self._create_decision_from_metadata(position, metadata)
604
+ except:
605
+ pass
606
+
607
+ return None
608
+
609
+ def _create_decision_from_metadata(self, position: Position, metadata: dict) -> Decision:
610
+ """Create a Decision object from position and metadata."""
611
+ from ankigammon.utils.xgid import encode_xgid
612
+
613
+ # Determine Crawford status from metadata
614
+ # Note: crawford_jacoby field means different things in different contexts:
615
+ # - Match play (match_length > 0): crawford_jacoby = 1 means Crawford rule
616
+ # - Money game (match_length = 0): crawford_jacoby = 1 means Jacoby rule
617
+ # The crawford boolean should ONLY be set for Crawford matches, not Jacoby money games
618
+ match_length = metadata.get('match_length', 0)
619
+ crawford = False
620
+
621
+ if match_length > 0: # Only set crawford=True for match play
622
+ if 'crawford' in metadata and metadata['crawford']:
623
+ crawford = True
624
+ elif 'crawford_jacoby' in metadata and metadata['crawford_jacoby'] > 0:
625
+ crawford = True
626
+ elif 'match_modifier' in metadata and metadata['match_modifier'] == 'C':
627
+ crawford = True
628
+
629
+ # Generate XGID for GnuBG analysis
630
+ xgid = encode_xgid(
631
+ position=position,
632
+ cube_value=metadata.get('cube_value', 1),
633
+ cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
634
+ dice=metadata.get('dice'),
635
+ on_roll=metadata.get('on_roll', Player.X),
636
+ score_x=metadata.get('score_x', 0),
637
+ score_o=metadata.get('score_o', 0),
638
+ match_length=metadata.get('match_length', 0),
639
+ crawford_jacoby=metadata.get('crawford_jacoby', 1 if crawford else 0)
640
+ )
641
+
642
+ return Decision(
643
+ position=position,
644
+ xgid=xgid,
645
+ on_roll=metadata.get('on_roll', Player.X),
646
+ dice=metadata.get('dice'),
647
+ score_x=metadata.get('score_x', 0),
648
+ score_o=metadata.get('score_o', 0),
649
+ match_length=metadata.get('match_length', 0),
650
+ crawford=crawford,
651
+ cube_value=metadata.get('cube_value', 1),
652
+ cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
653
+ decision_type=DecisionType.CUBE_ACTION if not metadata.get('dice') else DecisionType.CHECKER_PLAY,
654
+ candidate_moves=[] # Empty moves list - will be filled by GnuBG analysis
655
+ )
656
+
657
+ @Slot()
658
+ def _on_clear_all_clicked(self):
659
+ """Handle Clear All button click."""
660
+ if not self.pending_decisions:
661
+ return
662
+
663
+ reply = QMessageBox.question(
664
+ self,
665
+ "Clear All",
666
+ f"Remove all {len(self.pending_decisions)} pending position(s)?",
667
+ QMessageBox.Yes | QMessageBox.No
668
+ )
669
+
670
+ if reply == QMessageBox.Yes:
671
+ self.pending_decisions.clear()
672
+ self.pending_list.clear()
673
+ self._update_count_label()
674
+ self.preview.setHtml(self._get_empty_preview_html())
675
+
676
+ @Slot(list)
677
+ def _on_items_deleted(self, indices: list):
678
+ """Handle deletion of multiple pending items."""
679
+ # Sort indices in descending order and delete
680
+ for index in sorted(indices, reverse=True):
681
+ if 0 <= index < len(self.pending_decisions):
682
+ self.pending_decisions.pop(index)
683
+
684
+ # Update count label
685
+ self._update_count_label()
686
+
687
+ # Clear preview if no items remain or no selection
688
+ if not self.pending_decisions:
689
+ self.preview.setHtml(self._get_empty_preview_html())
690
+
691
+ @Slot(QListWidgetItem, QListWidgetItem)
692
+ def _on_selection_changed(self, current, previous):
693
+ """Handle selection change in pending list."""
694
+ if not current:
695
+ self.preview.setHtml(self._get_empty_preview_html())
696
+ return
697
+
698
+ if isinstance(current, PendingPositionItem):
699
+ self._show_preview(current.decision)
700
+
701
+ def _show_preview(self, decision: Decision):
702
+ """Show preview of a decision."""
703
+ svg = self.renderer.render_svg(
704
+ decision.position,
705
+ dice=decision.dice,
706
+ on_roll=decision.on_roll,
707
+ cube_value=decision.cube_value,
708
+ cube_owner=decision.cube_owner,
709
+ score_x=decision.score_x,
710
+ score_o=decision.score_o,
711
+ match_length=decision.match_length,
712
+ )
713
+
714
+ html = f"""
715
+ <!DOCTYPE html>
716
+ <html>
717
+ <head>
718
+ <style>
719
+ body {{
720
+ margin: 0;
721
+ padding: 10px;
722
+ background: #1e1e2e;
723
+ display: flex;
724
+ justify-content: center;
725
+ }}
726
+ svg {{
727
+ max-width: 100%;
728
+ height: auto;
729
+ }}
730
+ </style>
731
+ </head>
732
+ <body>
733
+ {svg}
734
+ </body>
735
+ </html>
736
+ """
737
+
738
+ self.preview.setHtml(html)
739
+
740
+ def _update_count_label(self):
741
+ """Update the pending count label."""
742
+ count = len(self.pending_decisions)
743
+ self.count_label.setText(f"{count} position{'s' if count != 1 else ''}")
744
+
745
+ def _get_empty_preview_html(self) -> str:
746
+ """Get HTML for empty preview state."""
747
+ return """
748
+ <!DOCTYPE html>
749
+ <html>
750
+ <head>
751
+ <style>
752
+ body {
753
+ margin: 0;
754
+ padding: 20px;
755
+ background: #1e1e2e;
756
+ color: #6c7086;
757
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
758
+ text-align: center;
759
+ }
760
+ </style>
761
+ </head>
762
+ <body>
763
+ <p>Select a position to preview</p>
764
+ </body>
765
+ </html>
766
+ """
767
+
768
+ def accept(self):
769
+ """Handle dialog acceptance."""
770
+ if self.pending_decisions:
771
+ self.positions_added.emit(self.pending_decisions)
772
+ super().accept()
773
+
774
+ def get_pending_decisions(self) -> List[Decision]:
775
+ """Get list of pending decisions."""
776
+ return self.pending_decisions