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
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,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,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
|
+
}
|