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,292 @@
1
+ """
2
+ Format detection for smart input handling.
3
+
4
+ Detects whether pasted text contains:
5
+ - Position IDs only (XGID/OGID/GNUID) - requires GnuBG analysis
6
+ - Full XG analysis text - ready to parse
7
+ """
8
+
9
+ import re
10
+ from typing import List, Tuple
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+
14
+ from ankigammon.settings import Settings
15
+
16
+
17
+ class InputFormat(Enum):
18
+ """Detected input format type."""
19
+ POSITION_IDS = "position_ids"
20
+ FULL_ANALYSIS = "full_analysis"
21
+ XG_BINARY = "xg_binary"
22
+ UNKNOWN = "unknown"
23
+
24
+
25
+ @dataclass
26
+ class DetectionResult:
27
+ """Result of format detection."""
28
+ format: InputFormat
29
+ count: int # Number of positions detected
30
+ details: str # Human-readable explanation
31
+ warnings: List[str] # Any warnings
32
+ position_previews: List[str] # Preview text for each position
33
+
34
+
35
+ class FormatDetector:
36
+ """Detects input format from pasted text."""
37
+
38
+ def __init__(self, settings: Settings):
39
+ self.settings = settings
40
+
41
+ def detect(self, text: str) -> DetectionResult:
42
+ """
43
+ Detect format from input text.
44
+
45
+ Algorithm:
46
+ 1. Split text into potential positions
47
+ 2. For each position, check for XGID/GNUID and analysis
48
+ 3. Classify based on what's present
49
+
50
+ Args:
51
+ text: Input text to analyze
52
+
53
+ Returns:
54
+ DetectionResult with format classification
55
+ """
56
+ text = text.strip()
57
+ if not text:
58
+ return DetectionResult(
59
+ format=InputFormat.UNKNOWN,
60
+ count=0,
61
+ details="No input",
62
+ warnings=[],
63
+ position_previews=[]
64
+ )
65
+
66
+ # Split into potential positions
67
+ positions = self._split_positions(text)
68
+
69
+ if not positions:
70
+ return DetectionResult(
71
+ format=InputFormat.UNKNOWN,
72
+ count=0,
73
+ details="No valid positions found",
74
+ warnings=["Could not parse input"],
75
+ position_previews=[]
76
+ )
77
+
78
+ # Analyze each position
79
+ position_types = []
80
+ previews = []
81
+
82
+ for pos_text in positions:
83
+ pos_type, preview = self._classify_position(pos_text)
84
+ position_types.append(pos_type)
85
+ previews.append(preview)
86
+
87
+ # Aggregate results
88
+ if all(pt == "position_id" for pt in position_types):
89
+ warnings = []
90
+ if not self.settings.is_gnubg_available():
91
+ warnings.append("GnuBG not configured - analysis required")
92
+
93
+ return DetectionResult(
94
+ format=InputFormat.POSITION_IDS,
95
+ count=len(positions),
96
+ details=f"{len(positions)} position ID(s) detected",
97
+ warnings=warnings,
98
+ position_previews=previews
99
+ )
100
+
101
+ elif all(pt == "full_analysis" for pt in position_types):
102
+ return DetectionResult(
103
+ format=InputFormat.FULL_ANALYSIS,
104
+ count=len(positions),
105
+ details=f"{len(positions)} full analysis position(s) detected",
106
+ warnings=[],
107
+ position_previews=previews
108
+ )
109
+
110
+ elif any(pt == "full_analysis" for pt in position_types) and any(pt == "position_id" for pt in position_types):
111
+ # Mixed input
112
+ full_count = sum(1 for pt in position_types if pt == "full_analysis")
113
+ id_count = sum(1 for pt in position_types if pt == "position_id")
114
+
115
+ warnings = []
116
+ if id_count > 0 and not self.settings.is_gnubg_available():
117
+ warnings.append(f"{id_count} position(s) need GnuBG analysis (not configured)")
118
+
119
+ return DetectionResult(
120
+ format=InputFormat.FULL_ANALYSIS, # Treat as full analysis, will handle IDs
121
+ count=len(positions),
122
+ details=f"Mixed input: {full_count} with analysis, {id_count} ID(s) only",
123
+ warnings=warnings,
124
+ position_previews=previews
125
+ )
126
+
127
+ else:
128
+ return DetectionResult(
129
+ format=InputFormat.UNKNOWN,
130
+ count=len(positions),
131
+ details="Unable to determine format",
132
+ warnings=["Check input format - should be XGID/OGID/GNUID or full XG analysis"],
133
+ position_previews=previews
134
+ )
135
+
136
+ def detect_binary(self, data: bytes) -> DetectionResult:
137
+ """
138
+ Detect format from binary data (for file imports).
139
+
140
+ Args:
141
+ data: Raw binary data from file
142
+
143
+ Returns:
144
+ DetectionResult with format classification
145
+ """
146
+ # Check for XG binary format
147
+ if self._is_xg_binary(data):
148
+ return DetectionResult(
149
+ format=InputFormat.XG_BINARY,
150
+ count=1, # Binary files typically contain 1 game (will be updated after parsing)
151
+ details="eXtreme Gammon binary file (.xg)",
152
+ warnings=[],
153
+ position_previews=["XG binary format"]
154
+ )
155
+
156
+ # Try decoding as text and use text detection
157
+ try:
158
+ text = data.decode('utf-8', errors='ignore')
159
+ return self.detect(text)
160
+ except:
161
+ return DetectionResult(
162
+ format=InputFormat.UNKNOWN,
163
+ count=0,
164
+ details="Unknown binary format",
165
+ warnings=["Could not parse binary data"],
166
+ position_previews=[]
167
+ )
168
+
169
+ def _is_xg_binary(self, data: bytes) -> bool:
170
+ """Check if data is XG binary format (.xg file)."""
171
+ if len(data) < 4:
172
+ return False
173
+ return data[0:4] == b'RGMH'
174
+
175
+ def _split_positions(self, text: str) -> List[str]:
176
+ """
177
+ Split text into individual position blocks.
178
+
179
+ Positions are separated by:
180
+ - Multiple blank lines (2+)
181
+ - "eXtreme Gammon Version:" marker
182
+ - New XGID/OGID/GNUID line after a complete position
183
+ """
184
+ # First, try splitting by eXtreme Gammon version markers
185
+ positions = []
186
+
187
+ # Split by XGID, OGID, or GNUID lines, keeping the position ID with its analysis
188
+ # Pattern matches XGID=, OGID (base-26), or GNUID (base64)
189
+ sections = re.split(r'(XGID=[^\n]+|^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}[^\n]*|^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12})', text, flags=re.MULTILINE)
190
+
191
+ # Recombine position ID with following content
192
+ current_pos = ""
193
+ for i, section in enumerate(sections):
194
+ # Check if this section starts with a position ID
195
+ if (section.startswith('XGID=') or
196
+ re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', section) or
197
+ re.match(r'^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12}$', section)):
198
+ if current_pos:
199
+ positions.append(current_pos.strip())
200
+ current_pos = section
201
+ elif section.strip():
202
+ current_pos += "\n" + section
203
+
204
+ if current_pos:
205
+ positions.append(current_pos.strip())
206
+
207
+ # Also check for simple line-by-line XGID/GNUID format
208
+ if not positions:
209
+ lines = [line.strip() for line in text.split('\n') if line.strip()]
210
+ if all(self._is_position_id_line(line) for line in lines):
211
+ positions = lines
212
+
213
+ return positions
214
+
215
+ def _is_position_id_line(self, line: str) -> bool:
216
+ """Check if a single line is a position ID (XGID, GNUID, or OGID)."""
217
+ # XGID format
218
+ if line.startswith('XGID='):
219
+ return True
220
+
221
+ # OGID format (base-26 encoding: 0-9a-p characters, at least 3 fields)
222
+ # Format: P1:P2:CUBE[:...] where P1 and P2 use only 0-9a-p
223
+ if re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', line):
224
+ return True
225
+
226
+ # GNUID format (base64: PositionID:MatchID = 14 chars:12 chars)
227
+ # Check for base64 chars after checking OGID to avoid confusion
228
+ if re.match(r'^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12}$', line):
229
+ return True
230
+
231
+ return False
232
+
233
+ def _classify_position(self, text: str) -> Tuple[str, str]:
234
+ """
235
+ Classify a single position block.
236
+
237
+ Returns:
238
+ (type, preview) where type is "position_id", "full_analysis", or "unknown"
239
+ """
240
+ has_xgid = 'XGID=' in text
241
+ has_ogid = bool(re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', text.strip()))
242
+ has_gnuid = bool(re.match(r'^[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$', text.strip()))
243
+
244
+ # Check for analysis markers
245
+ has_checker_play = bool(re.search(r'\beq:', text, re.IGNORECASE))
246
+ has_cube_decision = bool(re.search(r'Cubeful Equities:|Proper cube action:', text, re.IGNORECASE))
247
+ has_board = bool(re.search(r'\+13-14-15-16-17-18', text))
248
+
249
+ # Extract preview
250
+ preview = self._extract_preview(text, has_xgid, has_ogid, has_gnuid)
251
+
252
+ # Classification logic
253
+ if (has_xgid or has_ogid or has_gnuid):
254
+ if has_checker_play or has_cube_decision or has_board:
255
+ return ("full_analysis", preview)
256
+ else:
257
+ return ("position_id", preview)
258
+
259
+ return ("unknown", preview)
260
+
261
+ def _extract_preview(self, text: str, has_xgid: bool, has_ogid: bool, has_gnuid: bool) -> str:
262
+ """Extract a short preview of the position."""
263
+ if has_xgid:
264
+ match = re.search(r'XGID=([^\n]+)', text)
265
+ if match:
266
+ xgid = match.group(1)[:50] # First 50 chars
267
+
268
+ # Try to find player/dice info
269
+ player_match = re.search(r'([XO]) to play (\d+)', text)
270
+ if player_match:
271
+ player = player_match.group(1)
272
+ dice = player_match.group(2)
273
+ return f"{player} to play {dice}"
274
+
275
+ return f"XGID={xgid}..."
276
+
277
+ elif has_ogid:
278
+ # Extract player/dice from OGID if possible
279
+ parts = text.strip().split(':')
280
+ if len(parts) >= 5:
281
+ dice = parts[3] if len(parts) > 3 and parts[3] else "to roll"
282
+ turn = parts[4] if len(parts) > 4 and parts[4] else ""
283
+ # OGID color is inverted: W sent → B on roll, B sent → W on roll
284
+ player = "Black" if turn == "W" else "White" if turn == "B" else "?"
285
+ if dice and dice != "to roll":
286
+ return f"{player} to play {dice}"
287
+ return "OGID position"
288
+
289
+ elif has_gnuid:
290
+ return "GNUID position"
291
+
292
+ return "Unknown format"