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
ankigammon/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """AnkiGammon: Convert eXtreme Gammon analysis into Anki flashcards."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from ankigammon.models import Decision, Move, Position, CubeState
6
+
7
+ __all__ = ["Decision", "Move", "Position", "CubeState"]
ankigammon/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running AnkiGammon as a module."""
2
+
3
+ from ankigammon.gui.app import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """Analysis module for AnkiGammon."""
2
+
3
+ from ankigammon.analysis.score_matrix import (
4
+ ScoreMatrixCell,
5
+ generate_score_matrix,
6
+ format_matrix_as_html
7
+ )
8
+
9
+ __all__ = [
10
+ 'ScoreMatrixCell',
11
+ 'generate_score_matrix',
12
+ 'format_matrix_as_html'
13
+ ]
@@ -0,0 +1,373 @@
1
+ """
2
+ Score matrix generation for cube decisions.
3
+
4
+ Generates matrices showing optimal cube actions across all score combinations
5
+ in a match (e.g., 2a-2a through 7a-7a for a 7-point match).
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional
10
+
11
+
12
+ @dataclass
13
+ class ScoreMatrixCell:
14
+ """Represents one cell in the score matrix."""
15
+
16
+ player_away: int # Player on roll's score (away from match)
17
+ opponent_away: int # Opponent's score (away from match)
18
+ best_action: str # "D/T", "D/P", "N/T", "TG/T", "TG/P"
19
+ error_no_double: Optional[float] # Error if don't double
20
+ error_double: Optional[float] # Error if double/take
21
+ error_pass: Optional[float] # Error if pass
22
+
23
+ def format_errors(self) -> str:
24
+ """
25
+ Format error values for display in matrix.
26
+
27
+ Always displays errors in order: ND, D/T, D/P (skipping the cell's best action).
28
+ For example:
29
+ - N/T cell: shows D/T error, then D/P error
30
+ - D/T cell: shows ND error, then D/P error
31
+ - D/P cell: shows ND error, then D/T error
32
+
33
+ Returns:
34
+ String like "24/543" (errors scaled by 1000)
35
+ """
36
+ # Helper to scale error
37
+ def scale_error(error: Optional[float]) -> int:
38
+ return int(round(error * 1000)) if error is not None else 0
39
+
40
+ # Get all three errors
41
+ nd_error = scale_error(self.error_no_double)
42
+ dt_error = scale_error(self.error_double)
43
+ dp_error = scale_error(self.error_pass)
44
+
45
+ # Determine which two to show based on best action
46
+ # Order: ND, D/T, D/P (skip the one matching the cell)
47
+ best_action_upper = self.best_action.upper()
48
+
49
+ if best_action_upper in ["N/T", "TG/T", "TG/P"]:
50
+ # Skip ND error (TG is a variant of no double)
51
+ return f"{dt_error}/{dp_error}"
52
+ elif best_action_upper == "D/T":
53
+ # Skip D/T error
54
+ return f"{nd_error}/{dp_error}"
55
+ elif best_action_upper == "D/P":
56
+ # Skip D/P error
57
+ return f"{nd_error}/{dt_error}"
58
+ else:
59
+ # Fallback: show first two
60
+ return f"{nd_error}/{dt_error}"
61
+
62
+ def has_low_errors(self, threshold: int = 20) -> bool:
63
+ """
64
+ Check if the minimum displayed error is below the threshold.
65
+
66
+ This checks the two errors shown in the cell (not the one matching the best action).
67
+ If the smallest shown error is < threshold, it means at least one alternative
68
+ action is very close to the best action, indicating a close decision.
69
+
70
+ Args:
71
+ threshold: Error threshold (scaled by 1000). Default 20 = 0.020
72
+
73
+ Returns:
74
+ True if minimum of displayed errors is below threshold (close decision)
75
+ """
76
+ # Helper to scale error
77
+ def scale_error(error: Optional[float]) -> int:
78
+ return int(round(error * 1000)) if error is not None else 0
79
+
80
+ # Get all three errors
81
+ nd_error = scale_error(self.error_no_double)
82
+ dt_error = scale_error(self.error_double)
83
+ dp_error = scale_error(self.error_pass)
84
+
85
+ # Determine which two errors are displayed based on best action
86
+ best_action_upper = self.best_action.upper()
87
+
88
+ if best_action_upper in ["N/T", "TG/T", "TG/P"]:
89
+ # Display DT and DP errors
90
+ displayed_errors = [dt_error, dp_error]
91
+ elif best_action_upper == "D/T":
92
+ # Display ND and DP errors
93
+ displayed_errors = [nd_error, dp_error]
94
+ elif best_action_upper == "D/P":
95
+ # Display ND and DT errors
96
+ displayed_errors = [nd_error, dt_error]
97
+ else:
98
+ # Fallback: use ND and DT
99
+ displayed_errors = [nd_error, dt_error]
100
+
101
+ # Check if minimum of displayed errors is below threshold
102
+ return min(displayed_errors) < threshold
103
+
104
+
105
+ def generate_score_matrix(
106
+ xgid: str,
107
+ match_length: int,
108
+ gnubg_path: str,
109
+ ply_level: int = 3,
110
+ progress_callback: Optional[callable] = None,
111
+ use_parallel: bool = True
112
+ ) -> List[List[ScoreMatrixCell]]:
113
+ """
114
+ Generate a score matrix for all score combinations in a match.
115
+
116
+ Args:
117
+ xgid: XGID position string (cube decision)
118
+ match_length: Match length (e.g., 7 for 7-point match)
119
+ gnubg_path: Path to gnubg-cli.exe
120
+ ply_level: Analysis depth in plies
121
+ progress_callback: Optional callback(message: str) for progress updates
122
+ use_parallel: Use parallel analysis (default: True, ~5-9x faster)
123
+
124
+ Returns:
125
+ 2D list of ScoreMatrixCell objects, indexed as [row][col]
126
+ where row = player_away - 2, col = opponent_away - 2
127
+
128
+ For a 7-point match:
129
+ - Returns 6x6 matrix (2a through 7a)
130
+ - matrix[0][0] = 2a-2a
131
+ - matrix[5][5] = 7a-7a
132
+
133
+ Raises:
134
+ ValueError: If match_length < 2
135
+ FileNotFoundError: If gnubg_path doesn't exist
136
+ """
137
+ if match_length < 2:
138
+ raise ValueError(f"Match length must be >= 2, got {match_length}")
139
+
140
+ from ankigammon.utils.gnubg_analyzer import GNUBGAnalyzer
141
+ from ankigammon.utils.xgid import parse_xgid, encode_xgid
142
+ from ankigammon.models import Player
143
+ from ankigammon.parsers.gnubg_parser import GNUBGParser
144
+
145
+ # Initialize analyzer
146
+ analyzer = GNUBGAnalyzer(gnubg_path, ply_level)
147
+
148
+ # Parse original XGID to get position and metadata
149
+ position, metadata = parse_xgid(xgid)
150
+ on_roll = metadata.get('on_roll')
151
+
152
+ # Matrix size is (match_length - 1) x (match_length - 1)
153
+ # For 7-point match: 6x6 (scores from 2a to 7a)
154
+ matrix_size = match_length - 1
155
+
156
+ # Calculate total cells for progress
157
+ total_cells = matrix_size * matrix_size
158
+
159
+ # Prepare all position IDs and coordinate mappings
160
+ position_ids = []
161
+ coord_list = [] # [(player_away, opponent_away), ...]
162
+
163
+ for player_away in range(2, match_length + 1):
164
+ for opponent_away in range(2, match_length + 1):
165
+ # Calculate actual scores from "away" values
166
+ score_on_roll = match_length - player_away
167
+ score_opponent = match_length - opponent_away
168
+
169
+ # Map scores to X and O based on who's on roll
170
+ if on_roll == Player.O:
171
+ score_o = score_on_roll
172
+ score_x = score_opponent
173
+ else:
174
+ score_x = score_on_roll
175
+ score_o = score_opponent
176
+
177
+ # Create modified XGID with this score
178
+ modified_xgid = encode_xgid(
179
+ position=position,
180
+ cube_value=metadata.get('cube_value', 1),
181
+ cube_owner=metadata.get('cube_owner'),
182
+ dice=None, # Cube decision has no dice
183
+ on_roll=on_roll,
184
+ score_x=score_x,
185
+ score_o=score_o,
186
+ match_length=match_length,
187
+ crawford_jacoby=metadata.get('crawford_jacoby', 0),
188
+ max_cube=metadata.get('max_cube', 256)
189
+ )
190
+
191
+ position_ids.append(modified_xgid)
192
+ coord_list.append((player_away, opponent_away))
193
+
194
+ # Analyze all positions (parallel or sequential)
195
+ if use_parallel and len(position_ids) > 2:
196
+ # Parallel analysis with progress tracking
197
+ def parallel_progress_callback(completed: int, total: int):
198
+ if progress_callback:
199
+ # Get current coordinates for display
200
+ if completed > 0 and completed <= len(coord_list):
201
+ p_away, o_away = coord_list[completed - 1]
202
+ progress_callback(
203
+ f"Analyzing score {p_away}a-{o_away}a ({completed}/{total})..."
204
+ )
205
+
206
+ analysis_results = analyzer.analyze_positions_parallel(
207
+ position_ids,
208
+ progress_callback=parallel_progress_callback
209
+ )
210
+ else:
211
+ # Sequential analysis (fallback for small matrices or if disabled)
212
+ analysis_results = []
213
+ for idx, pos_id in enumerate(position_ids):
214
+ if progress_callback:
215
+ p_away, o_away = coord_list[idx]
216
+ progress_callback(
217
+ f"Analyzing score {p_away}a-{o_away}a ({idx + 1}/{total_cells})..."
218
+ )
219
+ analysis_results.append(analyzer.analyze_position(pos_id))
220
+
221
+ # Process results and build matrix
222
+ matrix = []
223
+ result_idx = 0
224
+
225
+ for player_away in range(2, match_length + 1):
226
+ row = []
227
+ for opponent_away in range(2, match_length + 1):
228
+ # Get analysis result
229
+ output, decision_type = analysis_results[result_idx]
230
+ result_idx += 1
231
+
232
+ # Parse cube decision
233
+ moves = GNUBGParser._parse_cube_decision(output)
234
+
235
+ if not moves:
236
+ raise ValueError(
237
+ f"Could not parse cube decision at score {player_away}a-{opponent_away}a"
238
+ )
239
+
240
+ # Build equity map
241
+ equity_map = {m.notation: m.equity for m in moves}
242
+
243
+ # Find best move
244
+ best_move = next((m for m in moves if m.rank == 1), None)
245
+ if not best_move:
246
+ raise ValueError(
247
+ f"Could not determine best cube action at score {player_away}a-{opponent_away}a"
248
+ )
249
+
250
+ # Get equities for the 3 main actions
251
+ no_double_eq = equity_map.get("No Double/Take", None)
252
+ double_take_eq = equity_map.get("Double/Take", equity_map.get("Redouble/Take", None))
253
+ double_pass_eq = equity_map.get("Double/Pass", equity_map.get("Redouble/Pass", None))
254
+
255
+ # Simplify best action notation
256
+ best_action_simplified = analyzer._simplify_cube_notation(best_move.notation)
257
+
258
+ # Calculate errors for wrong decisions
259
+ best_equity = best_move.equity
260
+ error_no_double = None
261
+ error_double = None
262
+ error_pass = None
263
+
264
+ if no_double_eq is not None:
265
+ error_no_double = abs(best_equity - no_double_eq) if best_action_simplified != "N/T" else 0.0
266
+ if double_take_eq is not None:
267
+ error_double = abs(best_equity - double_take_eq) if best_action_simplified not in ["D/T", "TG/T"] else 0.0
268
+ if double_pass_eq is not None:
269
+ error_pass = abs(best_equity - double_pass_eq) if best_action_simplified != "D/P" else 0.0
270
+
271
+ # Create cell
272
+ cell = ScoreMatrixCell(
273
+ player_away=player_away,
274
+ opponent_away=opponent_away,
275
+ best_action=best_action_simplified,
276
+ error_no_double=error_no_double,
277
+ error_double=error_double,
278
+ error_pass=error_pass
279
+ )
280
+ row.append(cell)
281
+
282
+ matrix.append(row)
283
+
284
+ return matrix
285
+
286
+
287
+ def format_matrix_as_html(
288
+ matrix: List[List[ScoreMatrixCell]],
289
+ current_player_away: Optional[int] = None,
290
+ current_opponent_away: Optional[int] = None
291
+ ) -> str:
292
+ """
293
+ Format score matrix as HTML table.
294
+
295
+ Args:
296
+ matrix: Score matrix from generate_score_matrix()
297
+ current_player_away: Highlight this cell (player's score away)
298
+ current_opponent_away: Highlight this cell (opponent's score away)
299
+
300
+ Returns:
301
+ HTML string with styled table
302
+ """
303
+ if not matrix or not matrix[0]:
304
+ return ""
305
+
306
+ matrix_size = len(matrix)
307
+
308
+ # Start table
309
+ html = '<div class="score-matrix">\n'
310
+ html += '<h3>Score Matrix for Initial Double</h3>\n'
311
+ html += '<table class="score-matrix-table">\n'
312
+
313
+ # Header row
314
+ html += '<tr><th></th>'
315
+ for col in range(matrix_size):
316
+ away = col + 2
317
+ html += f'<th>{away}a</th>'
318
+ html += '</tr>\n'
319
+
320
+ # Data rows
321
+ for row_idx, row in enumerate(matrix):
322
+ player_away = row_idx + 2
323
+ html += f'<tr><th>{player_away}a</th>'
324
+
325
+ for col_idx, cell in enumerate(row):
326
+ opponent_away = col_idx + 2
327
+
328
+ # Determine if this is the current cell
329
+ is_current = (
330
+ current_player_away == player_away and
331
+ current_opponent_away == opponent_away
332
+ )
333
+
334
+ # Get CSS class based on best action
335
+ action_class = _get_action_css_class(cell.best_action)
336
+ current_class = " current-score" if is_current else ""
337
+ low_error_class = " low-error" if cell.has_low_errors() else ""
338
+
339
+ html += f'<td class="{action_class}{current_class}{low_error_class}">'
340
+ html += f'<div class="action">{cell.best_action}</div>'
341
+ html += f'<div class="errors">{cell.format_errors()}</div>'
342
+ html += '</td>'
343
+
344
+ html += '</tr>\n'
345
+
346
+ html += '</table>\n'
347
+ html += '</div>\n'
348
+
349
+ return html
350
+
351
+
352
+ def _get_action_css_class(action: str) -> str:
353
+ """
354
+ Get CSS class name for cube action.
355
+
356
+ Args:
357
+ action: Cube action ("D/T", "N/T", etc.)
358
+
359
+ Returns:
360
+ CSS class name
361
+ """
362
+ action_upper = action.upper()
363
+
364
+ if action_upper == "D/T":
365
+ return "action-double-take"
366
+ elif action_upper == "D/P":
367
+ return "action-double-pass"
368
+ elif action_upper == "N/T":
369
+ return "action-no-double"
370
+ elif action_upper.startswith("TG"):
371
+ return "action-too-good"
372
+ else:
373
+ return "action-unknown"
@@ -0,0 +1,6 @@
1
+ """Anki export functionality."""
2
+
3
+ from ankigammon.anki.card_generator import CardGenerator
4
+ from ankigammon.anki.apkg_exporter import ApkgExporter
5
+
6
+ __all__ = ["CardGenerator", "ApkgExporter"]
@@ -0,0 +1,224 @@
1
+ """Anki-Connect integration for direct note creation in Anki."""
2
+
3
+ import json
4
+ import requests
5
+ from pathlib import Path
6
+ from typing import List, Dict, Any
7
+
8
+ from ankigammon.models import Decision
9
+ from ankigammon.anki.card_generator import CardGenerator
10
+ from ankigammon.anki.card_styles import MODEL_NAME, CARD_CSS
11
+
12
+
13
+ class AnkiConnect:
14
+ """
15
+ Interface to Anki via Anki-Connect addon.
16
+
17
+ Requires: Anki-Connect addon installed in Anki
18
+ https://ankiweb.net/shared/info/2055492159
19
+ """
20
+
21
+ def __init__(self, url: str = "http://localhost:8765", deck_name: str = "My AnkiGammon Deck"):
22
+ """
23
+ Initialize Anki-Connect client.
24
+
25
+ Args:
26
+ url: Anki-Connect API URL
27
+ deck_name: Target deck name
28
+ """
29
+ self.url = url
30
+ self.deck_name = deck_name
31
+
32
+ def invoke(self, action: str, **params) -> Any:
33
+ """
34
+ Invoke an Anki-Connect action.
35
+
36
+ Args:
37
+ action: Action name
38
+ **params: Action parameters
39
+
40
+ Returns:
41
+ Action result
42
+
43
+ Raises:
44
+ Exception: If request fails or Anki returns error
45
+ """
46
+ payload = {
47
+ 'action': action,
48
+ 'version': 6,
49
+ 'params': params
50
+ }
51
+
52
+ try:
53
+ response = requests.post(self.url, json=payload, timeout=5)
54
+ response.raise_for_status()
55
+ result = response.json()
56
+
57
+ if 'error' in result and result['error']:
58
+ raise Exception(f"Anki-Connect error: {result['error']}")
59
+
60
+ return result.get('result')
61
+
62
+ except requests.exceptions.ConnectionError as e:
63
+ raise Exception(
64
+ f"Could not connect to Anki-Connect at {self.url}. "
65
+ f"Make sure Anki is running and Anki-Connect addon is installed. "
66
+ f"Details: {str(e)}"
67
+ )
68
+ except requests.exceptions.Timeout:
69
+ raise Exception(
70
+ f"Connection to Anki-Connect at {self.url} timed out. "
71
+ "Make sure Anki is running and responsive."
72
+ )
73
+ except requests.exceptions.RequestException as e:
74
+ raise Exception(f"Request failed: {str(e)}")
75
+
76
+ def test_connection(self) -> bool:
77
+ """
78
+ Test connection to Anki-Connect.
79
+
80
+ Returns:
81
+ True if connection successful
82
+ """
83
+ try:
84
+ self.invoke('version')
85
+ return True
86
+ except Exception:
87
+ return False
88
+
89
+ def create_deck(self) -> None:
90
+ """Create the target deck if it doesn't exist."""
91
+ self.invoke('createDeck', deck=self.deck_name)
92
+
93
+ def create_model(self) -> None:
94
+ """Create the XG Backgammon note type if it doesn't exist."""
95
+ # Check if model already exists
96
+ model_names = self.invoke('modelNames')
97
+ if MODEL_NAME in model_names:
98
+ # Update the styling of existing model
99
+ self.invoke('updateModelStyling', model={'name': MODEL_NAME, 'css': CARD_CSS})
100
+ return
101
+
102
+ # Create new model
103
+ model = {
104
+ 'modelName': MODEL_NAME,
105
+ 'inOrderFields': ['Front', 'Back'],
106
+ 'css': CARD_CSS,
107
+ 'cardTemplates': [
108
+ {
109
+ 'Name': 'Card 1',
110
+ 'Front': '{{Front}}',
111
+ 'Back': '{{Back}}'
112
+ }
113
+ ]
114
+ }
115
+ self.invoke('createModel', **model)
116
+
117
+ def add_note(
118
+ self,
119
+ front: str,
120
+ back: str,
121
+ tags: List[str]
122
+ ) -> int:
123
+ """
124
+ Add a note to Anki.
125
+
126
+ Args:
127
+ front: Front HTML (with embedded SVG)
128
+ back: Back HTML (with embedded SVG)
129
+ tags: List of tags
130
+
131
+ Returns:
132
+ Note ID
133
+ """
134
+ # Create note
135
+ note = {
136
+ 'deckName': self.deck_name,
137
+ 'modelName': MODEL_NAME,
138
+ 'fields': {
139
+ 'Front': front,
140
+ 'Back': back,
141
+ },
142
+ 'tags': tags,
143
+ 'options': {
144
+ 'allowDuplicate': True
145
+ }
146
+ }
147
+
148
+ return self.invoke('addNote', note=note)
149
+
150
+ def export_decisions(
151
+ self,
152
+ decisions: List[Decision],
153
+ output_dir: Path,
154
+ show_options: bool = False,
155
+ color_scheme: str = "classic",
156
+ interactive_moves: bool = False,
157
+ orientation: str = "counter-clockwise"
158
+ ) -> Dict[str, Any]:
159
+ """
160
+ Export decisions directly to Anki via Anki-Connect.
161
+
162
+ Args:
163
+ decisions: List of Decision objects
164
+ output_dir: Directory for configuration (no media files needed)
165
+ show_options: Show multiple choice options (text-based)
166
+ color_scheme: Board color scheme name
167
+ interactive_moves: Enable interactive move visualization
168
+ orientation: Board orientation ("clockwise" or "counter-clockwise")
169
+
170
+ Returns:
171
+ Dictionary with export statistics
172
+ """
173
+ # Test connection
174
+ if not self.test_connection():
175
+ raise Exception("Cannot connect to Anki-Connect")
176
+
177
+ # Create model and deck
178
+ self.create_model()
179
+ self.create_deck()
180
+
181
+ # Create renderer with color scheme
182
+ from ankigammon.renderer.color_schemes import get_scheme
183
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
184
+
185
+ scheme = get_scheme(color_scheme)
186
+ renderer = SVGBoardRenderer(color_scheme=scheme, orientation=orientation)
187
+
188
+ # Create card generator
189
+ card_gen = CardGenerator(
190
+ output_dir=output_dir,
191
+ show_options=show_options,
192
+ interactive_moves=interactive_moves,
193
+ renderer=renderer
194
+ )
195
+
196
+ # Generate and add cards
197
+ added = 0
198
+ skipped = 0
199
+ errors = []
200
+
201
+ for i, decision in enumerate(decisions):
202
+ try:
203
+ card_data = card_gen.generate_card(decision, card_id=f"card_{i}")
204
+
205
+ note_id = self.add_note(
206
+ front=card_data['front'],
207
+ back=card_data['back'],
208
+ tags=card_data['tags']
209
+ )
210
+
211
+ if note_id:
212
+ added += 1
213
+ else:
214
+ skipped += 1
215
+
216
+ except Exception as e:
217
+ errors.append(f"Card {i}: {str(e)}")
218
+
219
+ return {
220
+ 'added': added,
221
+ 'skipped': skipped,
222
+ 'errors': errors,
223
+ 'total': len(decisions)
224
+ }