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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -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 +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -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 +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- 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"
|