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