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,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"
|