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,290 @@
1
+ """SGF (Smart Game Format) parser for backgammon.
2
+
3
+ SGF is a standard format for recording board games. For backgammon, it uses GM[6].
4
+ Specification: https://www.red-bean.com/sgf/backgammon.html
5
+
6
+ Move notation uses perspective-dependent point encoding:
7
+ - Points a-x represent points 1-24
8
+ - y = bar, z = off
9
+
10
+ For White (moving 1→24): a=1, b=2, c=3... x=24
11
+ For Black (moving 24→1): a=24, b=23, c=22... x=1
12
+
13
+ Example: B[52lqac] (Black rolls 5-2)
14
+ - lq: l(13) → q(8) = 13/8
15
+ - ac: a(24) → c(22) = 24/22
16
+ """
17
+
18
+ import re
19
+ from typing import List, Dict, Tuple, Optional
20
+ from pathlib import Path
21
+
22
+
23
+ class SGFParser:
24
+ """Parser for SGF backgammon match files."""
25
+
26
+ # SGF point letters (a-x = points, y = bar, z = off)
27
+ POINT_LETTERS = 'abcdefghijklmnopqrstuvwxyz'
28
+
29
+ @staticmethod
30
+ def sgf_letter_to_point(letter: str, player: str) -> Optional[int]:
31
+ """Convert SGF letter to backgammon point number.
32
+
33
+ Args:
34
+ letter: Single letter (a-x=points, y=bar, z=off)
35
+ player: 'W' for White or 'B' for Black
36
+
37
+ Returns:
38
+ Point number (1-24) or special values:
39
+ - 0 for bar
40
+ - 25 for off
41
+ - None for invalid letter
42
+ """
43
+ if letter == 'y':
44
+ return 0
45
+ elif letter == 'z':
46
+ return 25
47
+ elif letter in SGFParser.POINT_LETTERS[:24]:
48
+ index = SGFParser.POINT_LETTERS.index(letter)
49
+
50
+ if player == 'W':
51
+ return index + 1
52
+ else:
53
+ return 24 - index
54
+ else:
55
+ return None
56
+
57
+ @staticmethod
58
+ def parse_sgf_move(move_str: str, player: str) -> Tuple[str, str]:
59
+ """Parse SGF move notation to dice and standard notation.
60
+
61
+ Args:
62
+ move_str: SGF move string like "52lqac" or "double"
63
+ player: 'W' for White or 'B' for Black
64
+
65
+ Returns:
66
+ Tuple of (dice, move_notation)
67
+ - dice: "52" or "" for special moves
68
+ - move_notation: "13/8 24/22" or "Doubles" etc.
69
+ """
70
+ # Handle special cube actions
71
+ if move_str == 'double':
72
+ return "", "Doubles => 2"
73
+ elif move_str == 'take':
74
+ return "", "Takes"
75
+ elif move_str == 'drop':
76
+ return "", "Drops"
77
+
78
+ if len(move_str) < 2:
79
+ return "", ""
80
+
81
+ dice = move_str[:2]
82
+ if not dice.isdigit():
83
+ return "", ""
84
+
85
+ move_part = move_str[2:]
86
+ if len(move_part) % 2 != 0:
87
+ return dice, ""
88
+
89
+ moves = []
90
+ for i in range(0, len(move_part), 2):
91
+ from_letter = move_part[i]
92
+ to_letter = move_part[i + 1]
93
+
94
+ from_point = SGFParser.sgf_letter_to_point(from_letter, player)
95
+ to_point = SGFParser.sgf_letter_to_point(to_letter, player)
96
+
97
+ if from_point is None or to_point is None:
98
+ continue
99
+
100
+ from_str = 'bar' if from_point == 0 else str(from_point)
101
+ to_str = 'off' if to_point == 25 else str(to_point)
102
+
103
+ moves.append(f"{from_str}/{to_str}")
104
+
105
+ move_notation = " ".join(moves) if moves else ""
106
+ return dice, move_notation
107
+
108
+ @staticmethod
109
+ def parse_sgf_file(file_path: str) -> Dict:
110
+ """Parse SGF file and extract match information.
111
+
112
+ Args:
113
+ file_path: Path to .sgf file
114
+
115
+ Returns:
116
+ Dictionary with:
117
+ - player_white: White player name
118
+ - player_black: Black player name
119
+ - match_length: Match length (0 for money game)
120
+ - games: List of game dictionaries
121
+ """
122
+ with open(file_path, 'r', encoding='utf-8') as f:
123
+ content = f.read()
124
+
125
+ games_raw = re.findall(r'\(([^()]+)\)', content)
126
+
127
+ games = []
128
+ player_white = None
129
+ player_black = None
130
+ match_length = 0
131
+
132
+ for game_raw in games_raw:
133
+ game_data = SGFParser._parse_game(game_raw)
134
+
135
+ if player_white is None:
136
+ player_white = game_data.get('player_white', 'White')
137
+ if player_black is None:
138
+ player_black = game_data.get('player_black', 'Black')
139
+ if match_length == 0:
140
+ match_length = game_data.get('match_length', 0)
141
+
142
+ games.append(game_data)
143
+
144
+ return {
145
+ 'player_white': player_white,
146
+ 'player_black': player_black,
147
+ 'match_length': match_length,
148
+ 'games': games
149
+ }
150
+
151
+ @staticmethod
152
+ def _parse_game(game_str: str) -> Dict:
153
+ """Parse a single SGF game.
154
+
155
+ Args:
156
+ game_str: SGF game string (content between parentheses)
157
+
158
+ Returns:
159
+ Dictionary with game metadata and moves
160
+ """
161
+ game_data = {
162
+ 'moves': []
163
+ }
164
+
165
+ parts = game_str.split(';')
166
+
167
+ for part in parts:
168
+ part = part.strip()
169
+ if not part:
170
+ continue
171
+
172
+ props = re.findall(r'([A-Z]+)\[([^\]]*)\]', part)
173
+
174
+ for prop_name, prop_value in props:
175
+ if prop_name == 'PW':
176
+ game_data['player_white'] = prop_value
177
+ elif prop_name == 'PB':
178
+ game_data['player_black'] = prop_value
179
+ elif prop_name == 'RE':
180
+ game_data['result'] = prop_value
181
+ elif prop_name == 'RU':
182
+ game_data['rules'] = prop_value
183
+ elif prop_name == 'MI':
184
+ mi_match = re.search(r'length:(\d+)', prop_value)
185
+ if mi_match:
186
+ game_data['match_length'] = int(mi_match.group(1))
187
+
188
+ game_match = re.search(r'game:(\d+)', prop_value)
189
+ if game_match:
190
+ game_data['game_number'] = int(game_match.group(1))
191
+
192
+ bs_match = re.search(r'bs:(\d+)', prop_value)
193
+ if bs_match:
194
+ game_data['black_score'] = int(bs_match.group(1))
195
+
196
+ ws_match = re.search(r'ws:(\d+)', prop_value)
197
+ if ws_match:
198
+ game_data['white_score'] = int(ws_match.group(1))
199
+
200
+ move_match = re.match(r'^([BW])\[([^\]]+)\]', part)
201
+ if move_match:
202
+ player = move_match.group(1)
203
+ move_str = move_match.group(2)
204
+
205
+ dice, notation = SGFParser.parse_sgf_move(move_str, player)
206
+
207
+ game_data['moves'].append({
208
+ 'player': player,
209
+ 'dice': dice,
210
+ 'notation': notation,
211
+ 'raw': move_str
212
+ })
213
+
214
+ return game_data
215
+
216
+ @staticmethod
217
+ def convert_to_mat_format(sgf_data: Dict) -> str:
218
+ """Convert parsed SGF data to .mat (match text) format.
219
+
220
+ Args:
221
+ sgf_data: Parsed SGF data from parse_sgf_file()
222
+
223
+ Returns:
224
+ String in .mat format compatible with GnuBG
225
+ """
226
+ lines = []
227
+
228
+ player_white = sgf_data.get('player_white', 'White')
229
+ player_black = sgf_data.get('player_black', 'Black')
230
+ match_length = sgf_data.get('match_length', 0)
231
+
232
+ lines.append(f" {match_length} point match")
233
+ lines.append("")
234
+
235
+ for game_idx, game in enumerate(sgf_data['games'], start=1):
236
+ white_score = game.get('white_score', 0)
237
+ black_score = game.get('black_score', 0)
238
+
239
+ lines.append(f" Game {game_idx}")
240
+ lines.append(f" {player_black} : {black_score} {player_white} : {white_score}")
241
+
242
+ move_num = 0
243
+ for move in game['moves']:
244
+ player_name = player_white if move['player'] == 'W' else player_black
245
+ dice = move['dice']
246
+ notation = move['notation']
247
+
248
+ if 'Doubles' in notation:
249
+ lines.append(f" {move_num}) Doubles => 2")
250
+ elif 'Takes' in notation:
251
+ lines.append(f" {move_num}) Takes")
252
+ elif 'Drops' in notation:
253
+ lines.append(f" {move_num}) Drops")
254
+ else:
255
+ if dice:
256
+ move_num += 1
257
+ lines.append(f" {move_num}) {dice}: {notation}")
258
+
259
+ result = game.get('result', '')
260
+ if result:
261
+ result_match = re.match(r'([WB])\+(\d+)', result)
262
+ if result_match:
263
+ winner = 'White' if result_match.group(1) == 'W' else 'Black'
264
+ points = result_match.group(2)
265
+ lines.append(f" Wins {points} point{'s' if points != '1' else ''}")
266
+
267
+ lines.append("")
268
+
269
+ return "\n".join(lines)
270
+
271
+
272
+ def extract_player_names_from_sgf(sgf_file_path: str) -> Tuple[str, str]:
273
+ """Extract player names from SGF file.
274
+
275
+ Args:
276
+ sgf_file_path: Path to .sgf file
277
+
278
+ Returns:
279
+ Tuple of (player1_name, player2_name) where:
280
+ - player1 goes to top checkbox (Player.O filter)
281
+ - player2 goes to bottom checkbox (Player.X filter)
282
+ """
283
+ try:
284
+ data = SGFParser.parse_sgf_file(sgf_file_path)
285
+ return (
286
+ data.get('player_white', 'Player 1'),
287
+ data.get('player_black', 'Player 2')
288
+ )
289
+ except Exception:
290
+ return ('Player 1', 'Player 2')