ankigammon 1.0.6__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.
Files changed (61) 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 +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. ankigammon-1.0.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,377 @@
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
+ MATCH_FILE = "match_file"
23
+ SGF_FILE = "sgf_file"
24
+ UNKNOWN = "unknown"
25
+
26
+
27
+ @dataclass
28
+ class DetectionResult:
29
+ """Result of format detection."""
30
+ format: InputFormat
31
+ count: int # Number of positions detected
32
+ details: str # Human-readable explanation
33
+ warnings: List[str] # Any warnings
34
+ position_previews: List[str] # Preview text for each position
35
+
36
+
37
+ class FormatDetector:
38
+ """Detects input format from pasted text."""
39
+
40
+ def __init__(self, settings: Settings):
41
+ self.settings = settings
42
+
43
+ def detect(self, text: str) -> DetectionResult:
44
+ """
45
+ Detect format from input text.
46
+
47
+ Splits text into positions, checks for position IDs and analysis markers,
48
+ then classifies as position IDs only or full analysis.
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
+ full_count = sum(1 for pt in position_types if pt == "full_analysis")
112
+ id_count = sum(1 for pt in position_types if pt == "position_id")
113
+
114
+ warnings = []
115
+ if id_count > 0 and not self.settings.is_gnubg_available():
116
+ warnings.append(f"{id_count} position(s) need GnuBG analysis (not configured)")
117
+
118
+ return DetectionResult(
119
+ format=InputFormat.FULL_ANALYSIS,
120
+ count=len(positions),
121
+ details=f"Mixed input: {full_count} with analysis, {id_count} ID(s) only",
122
+ warnings=warnings,
123
+ position_previews=previews
124
+ )
125
+
126
+ else:
127
+ return DetectionResult(
128
+ format=InputFormat.UNKNOWN,
129
+ count=len(positions),
130
+ details="Unable to determine format",
131
+ warnings=["Check input format - should be XGID/OGID/GNUID or full XG analysis"],
132
+ position_previews=previews
133
+ )
134
+
135
+ def detect_binary(self, data: bytes) -> DetectionResult:
136
+ """
137
+ Detect format from binary data for file imports.
138
+
139
+ Args:
140
+ data: Raw binary data from file
141
+
142
+ Returns:
143
+ DetectionResult with format classification
144
+ """
145
+ if self._is_xg_binary(data):
146
+ return DetectionResult(
147
+ format=InputFormat.XG_BINARY,
148
+ count=1,
149
+ details="eXtreme Gammon binary file (.xg)",
150
+ warnings=[],
151
+ position_previews=["XG binary format"]
152
+ )
153
+
154
+ if FormatDetector.is_sgf_file(data):
155
+ warnings = []
156
+ if not self.settings.is_gnubg_available():
157
+ warnings.append("GnuBG required for match analysis (not configured)")
158
+
159
+ return DetectionResult(
160
+ format=InputFormat.SGF_FILE,
161
+ count=1,
162
+ details="SGF backgammon match file (.sgf)",
163
+ warnings=warnings,
164
+ position_previews=["SGF file - requires analysis"]
165
+ )
166
+
167
+ if FormatDetector.is_match_file(data):
168
+ warnings = []
169
+ if not self.settings.is_gnubg_available():
170
+ warnings.append("GnuBG required for match analysis (not configured)")
171
+
172
+ return DetectionResult(
173
+ format=InputFormat.MATCH_FILE,
174
+ count=1,
175
+ details="Backgammon match file (.mat)",
176
+ warnings=warnings,
177
+ position_previews=["Match file - requires analysis"]
178
+ )
179
+
180
+ try:
181
+ text = data.decode('utf-8', errors='ignore')
182
+ return self.detect(text)
183
+ except:
184
+ return DetectionResult(
185
+ format=InputFormat.UNKNOWN,
186
+ count=0,
187
+ details="Unknown binary format",
188
+ warnings=["Could not parse binary data"],
189
+ position_previews=[]
190
+ )
191
+
192
+ def _is_xg_binary(self, data: bytes) -> bool:
193
+ """Check if data is XG binary format (.xg file)."""
194
+ if len(data) < 4:
195
+ return False
196
+ return data[0:4] == b'RGMH'
197
+
198
+ @staticmethod
199
+ def is_match_file(data: bytes) -> bool:
200
+ """
201
+ Check if data is a backgammon match file.
202
+
203
+ Supports header format (OpenGammon, Backgammon Studio) with semicolon
204
+ comments, or plain text format with match indicators.
205
+
206
+ Args:
207
+ data: Raw file data
208
+
209
+ Returns:
210
+ True if this is a match file
211
+ """
212
+ try:
213
+ text = data.decode('utf-8', errors='ignore')
214
+
215
+ # Strip UTF-8 BOM if present (not considered whitespace by lstrip())
216
+ text = text.lstrip('\ufeff').lstrip()
217
+
218
+ if text.startswith(';'):
219
+ return True
220
+
221
+ first_lines = '\n'.join(text.split('\n')[:10])
222
+ if re.search(r'\d+\s+point\s+match', first_lines, re.IGNORECASE):
223
+ return True
224
+
225
+ header = text[:500]
226
+ match_indicators = [
227
+ 'point match',
228
+ 'Game 1',
229
+ 'Doubles =>',
230
+ 'Takes',
231
+ 'Drops',
232
+ 'Wins.*point'
233
+ ]
234
+
235
+ matches = sum(1 for indicator in match_indicators
236
+ if re.search(indicator, header, re.IGNORECASE))
237
+
238
+ return matches >= 3
239
+
240
+ except:
241
+ return False
242
+
243
+ @staticmethod
244
+ def is_sgf_file(data: bytes) -> bool:
245
+ """
246
+ Check if data is an SGF (Smart Game Format) backgammon file.
247
+
248
+ Verifies SGF structure with format 4 and game type 6 (backgammon).
249
+
250
+ Args:
251
+ data: Raw file data
252
+
253
+ Returns:
254
+ True if this is an SGF backgammon file
255
+ """
256
+ try:
257
+ text = data.decode('utf-8', errors='ignore')
258
+
259
+ if not text.lstrip().startswith('(;'):
260
+ return False
261
+
262
+ if 'GM[6]' not in text[:200]:
263
+ return False
264
+
265
+ sgf_indicators = [
266
+ 'FF[4]',
267
+ 'GM[6]',
268
+ 'PB[',
269
+ 'PW[',
270
+ ]
271
+
272
+ matches = sum(1 for indicator in sgf_indicators if indicator in text[:500])
273
+
274
+ return matches >= 3
275
+
276
+ except:
277
+ return False
278
+
279
+ def _split_positions(self, text: str) -> List[str]:
280
+ """
281
+ Split text into individual position blocks.
282
+
283
+ Separates positions by XGID/OGID/GNUID markers, keeping position IDs
284
+ with their associated analysis content.
285
+ """
286
+ positions = []
287
+
288
+ 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)
289
+
290
+ current_pos = ""
291
+ for i, section in enumerate(sections):
292
+ if (section.startswith('XGID=') or
293
+ re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', section) or
294
+ re.match(r'^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12}$', section)):
295
+ if current_pos:
296
+ positions.append(current_pos.strip())
297
+ current_pos = section
298
+ elif section.strip():
299
+ current_pos += "\n" + section
300
+
301
+ if current_pos:
302
+ positions.append(current_pos.strip())
303
+
304
+ if not positions:
305
+ lines = [line.strip() for line in text.split('\n') if line.strip()]
306
+ if all(self._is_position_id_line(line) for line in lines):
307
+ positions = lines
308
+
309
+ return positions
310
+
311
+ def _is_position_id_line(self, line: str) -> bool:
312
+ """Check if a single line is a position ID (XGID, GNUID, or OGID)."""
313
+ if line.startswith('XGID='):
314
+ return True
315
+
316
+ if re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', line):
317
+ return True
318
+
319
+ if re.match(r'^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12}$', line):
320
+ return True
321
+
322
+ return False
323
+
324
+ def _classify_position(self, text: str) -> Tuple[str, str]:
325
+ """
326
+ Classify a single position block as position ID, full analysis, or unknown.
327
+
328
+ Returns:
329
+ (type, preview) tuple
330
+ """
331
+ has_xgid = 'XGID=' in text
332
+ has_ogid = bool(re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', text.strip()))
333
+ has_gnuid = bool(re.match(r'^[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$', text.strip()))
334
+
335
+ has_checker_play = bool(re.search(r'\beq:', text, re.IGNORECASE))
336
+ has_cube_decision = bool(re.search(r'Cubeful Equities:|Proper cube action:', text, re.IGNORECASE))
337
+ has_board = bool(re.search(r'\+13-14-15-16-17-18', text))
338
+
339
+ preview = self._extract_preview(text, has_xgid, has_ogid, has_gnuid)
340
+
341
+ if (has_xgid or has_ogid or has_gnuid):
342
+ if has_checker_play or has_cube_decision or has_board:
343
+ return ("full_analysis", preview)
344
+ else:
345
+ return ("position_id", preview)
346
+
347
+ return ("unknown", preview)
348
+
349
+ def _extract_preview(self, text: str, has_xgid: bool, has_ogid: bool, has_gnuid: bool) -> str:
350
+ """Extract a short preview of the position."""
351
+ if has_xgid:
352
+ match = re.search(r'XGID=([^\n]+)', text)
353
+ if match:
354
+ xgid = match.group(1)[:50]
355
+
356
+ player_match = re.search(r'([XO]) to play (\d+)', text)
357
+ if player_match:
358
+ player = player_match.group(1)
359
+ dice = player_match.group(2)
360
+ return f"{player} to play {dice}"
361
+
362
+ return f"XGID={xgid}..."
363
+
364
+ elif has_ogid:
365
+ parts = text.strip().split(':')
366
+ if len(parts) >= 5:
367
+ dice = parts[3] if len(parts) > 3 and parts[3] else "to roll"
368
+ turn = parts[4] if len(parts) > 4 and parts[4] else ""
369
+ player = "Black" if turn == "W" else "White" if turn == "B" else "?"
370
+ if dice and dice != "to roll":
371
+ return f"{player} to play {dice}"
372
+ return "OGID position"
373
+
374
+ elif has_gnuid:
375
+ return "GNUID position"
376
+
377
+ return "Unknown format"