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
ankigammon/settings.py ADDED
@@ -0,0 +1,315 @@
1
+ """
2
+ Settings and configuration management for AnkiGammon.
3
+
4
+ Handles loading and saving user preferences such as color scheme selection.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+
13
+ class Settings:
14
+ """Manages application settings with persistence."""
15
+
16
+ DEFAULT_SETTINGS = {
17
+ "default_color_scheme": "classic",
18
+ "deck_name": "My AnkiGammon Deck",
19
+ "show_options": True,
20
+ "interactive_moves": True,
21
+ "export_method": "ankiconnect",
22
+ "gnubg_path": None,
23
+ "gnubg_analysis_ply": 3,
24
+ "generate_score_matrix": False,
25
+ "board_orientation": "counter-clockwise",
26
+ "last_apkg_directory": None,
27
+ "import_error_threshold": 0.080,
28
+ "import_include_player_x": True,
29
+ "import_include_player_o": True,
30
+ "import_selected_player_names": [],
31
+ "max_mcq_options": 5,
32
+ "check_for_updates": True,
33
+ "last_update_check": None,
34
+ "snooze_update_until": None,
35
+ }
36
+
37
+ def __init__(self, config_path: Optional[Path] = None):
38
+ """
39
+ Initialize settings manager.
40
+
41
+ Args:
42
+ config_path: Path to config file. If None, uses default location.
43
+ """
44
+ if config_path is None:
45
+ config_dir = Path.home() / ".ankigammon"
46
+ config_dir.mkdir(parents=True, exist_ok=True)
47
+ config_path = config_dir / "config.json"
48
+
49
+ self.config_path = config_path
50
+ self._settings = self._load()
51
+
52
+ def _load(self) -> dict:
53
+ """Load settings from config file."""
54
+ if not self.config_path.exists():
55
+ return self.DEFAULT_SETTINGS.copy()
56
+
57
+ try:
58
+ with open(self.config_path, 'r', encoding='utf-8') as f:
59
+ loaded = json.load(f)
60
+ # Merge with defaults to ensure new settings have default values
61
+ settings = self.DEFAULT_SETTINGS.copy()
62
+ settings.update(loaded)
63
+ return settings
64
+ except (json.JSONDecodeError, IOError):
65
+ # Use defaults if file is corrupted or unreadable
66
+ return self.DEFAULT_SETTINGS.copy()
67
+
68
+ def _save(self) -> None:
69
+ """Save settings to config file."""
70
+ try:
71
+ with open(self.config_path, 'w', encoding='utf-8') as f:
72
+ json.dump(self._settings, f, indent=2)
73
+ except IOError:
74
+ # Silently fail if unable to save
75
+ pass
76
+
77
+ def get(self, key: str, default=None):
78
+ """Get a setting value."""
79
+ return self._settings.get(key, default)
80
+
81
+ def set(self, key: str, value) -> None:
82
+ """Set a setting value and save to disk."""
83
+ self._settings[key] = value
84
+ self._save()
85
+
86
+ @property
87
+ def color_scheme(self) -> str:
88
+ """Get the default color scheme."""
89
+ return self._settings.get("default_color_scheme", "classic")
90
+
91
+ @color_scheme.setter
92
+ def color_scheme(self, value: str) -> None:
93
+ """Set the default color scheme."""
94
+ self.set("default_color_scheme", value)
95
+
96
+ @property
97
+ def deck_name(self) -> str:
98
+ """Get the default deck name."""
99
+ return self._settings.get("deck_name", "My AnkiGammon Deck")
100
+
101
+ @deck_name.setter
102
+ def deck_name(self, value: str) -> None:
103
+ """Set the default deck name."""
104
+ self.set("deck_name", value)
105
+
106
+ @property
107
+ def show_options(self) -> bool:
108
+ """Get whether to show options on cards."""
109
+ return self._settings.get("show_options", True)
110
+
111
+ @show_options.setter
112
+ def show_options(self, value: bool) -> None:
113
+ """Set whether to show options on cards."""
114
+ self.set("show_options", value)
115
+
116
+ @property
117
+ def interactive_moves(self) -> bool:
118
+ """Get whether to enable interactive move visualization."""
119
+ return self._settings.get("interactive_moves", True)
120
+
121
+ @interactive_moves.setter
122
+ def interactive_moves(self, value: bool) -> None:
123
+ """Set whether to enable interactive move visualization."""
124
+ self.set("interactive_moves", value)
125
+
126
+ @property
127
+ def export_method(self) -> str:
128
+ """Get the default export method."""
129
+ return self._settings.get("export_method", "ankiconnect")
130
+
131
+ @export_method.setter
132
+ def export_method(self, value: str) -> None:
133
+ """Set the default export method."""
134
+ self.set("export_method", value)
135
+
136
+ @property
137
+ def gnubg_path(self) -> Optional[str]:
138
+ """Get the GnuBG executable path."""
139
+ return self._settings.get("gnubg_path", None)
140
+
141
+ @gnubg_path.setter
142
+ def gnubg_path(self, value: Optional[str]) -> None:
143
+ """Set the GnuBG executable path."""
144
+ self.set("gnubg_path", value)
145
+
146
+ @property
147
+ def gnubg_analysis_ply(self) -> int:
148
+ """Get the GnuBG analysis depth (ply)."""
149
+ return self._settings.get("gnubg_analysis_ply", 3)
150
+
151
+ @gnubg_analysis_ply.setter
152
+ def gnubg_analysis_ply(self, value: int) -> None:
153
+ """Set the GnuBG analysis depth (ply)."""
154
+ self.set("gnubg_analysis_ply", value)
155
+
156
+ @property
157
+ def generate_score_matrix(self) -> bool:
158
+ """Get whether to generate score matrix for cube decisions."""
159
+ return self._settings.get("generate_score_matrix", False)
160
+
161
+ @generate_score_matrix.setter
162
+ def generate_score_matrix(self, value: bool) -> None:
163
+ """Set whether to generate score matrix for cube decisions."""
164
+ self.set("generate_score_matrix", value)
165
+
166
+ @property
167
+ def board_orientation(self) -> str:
168
+ """Get the board orientation (clockwise or counter-clockwise)."""
169
+ return self._settings.get("board_orientation", "counter-clockwise")
170
+
171
+ @board_orientation.setter
172
+ def board_orientation(self, value: str) -> None:
173
+ """Set the board orientation (clockwise or counter-clockwise)."""
174
+ if value not in ["clockwise", "counter-clockwise"]:
175
+ raise ValueError("board_orientation must be 'clockwise' or 'counter-clockwise'")
176
+ self.set("board_orientation", value)
177
+
178
+ @property
179
+ def last_apkg_directory(self) -> Optional[str]:
180
+ """Get the last directory used for APKG export."""
181
+ return self._settings.get("last_apkg_directory", None)
182
+
183
+ @last_apkg_directory.setter
184
+ def last_apkg_directory(self, value: Optional[str]) -> None:
185
+ """Set the last directory used for APKG export."""
186
+ self.set("last_apkg_directory", value)
187
+
188
+ @property
189
+ def import_error_threshold(self) -> float:
190
+ """Get the error threshold for XG file imports."""
191
+ return self._settings.get("import_error_threshold", 0.080)
192
+
193
+ @import_error_threshold.setter
194
+ def import_error_threshold(self, value: float) -> None:
195
+ """Set the error threshold for XG file imports."""
196
+ self.set("import_error_threshold", value)
197
+
198
+ @property
199
+ def import_include_player_x(self) -> bool:
200
+ """Get whether to include Player X mistakes in imports."""
201
+ return self._settings.get("import_include_player_x", True)
202
+
203
+ @import_include_player_x.setter
204
+ def import_include_player_x(self, value: bool) -> None:
205
+ """Set whether to include Player X mistakes in imports."""
206
+ self.set("import_include_player_x", value)
207
+
208
+ @property
209
+ def import_include_player_o(self) -> bool:
210
+ """Get whether to include Player O mistakes in imports."""
211
+ return self._settings.get("import_include_player_o", True)
212
+
213
+ @import_include_player_o.setter
214
+ def import_include_player_o(self, value: bool) -> None:
215
+ """Set whether to include Player O mistakes in imports."""
216
+ self.set("import_include_player_o", value)
217
+
218
+ @property
219
+ def import_selected_player_names(self) -> list[str]:
220
+ """Get the list of previously selected player names."""
221
+ return self._settings.get("import_selected_player_names", [])
222
+
223
+ @import_selected_player_names.setter
224
+ def import_selected_player_names(self, value: list[str]) -> None:
225
+ """Set the list of previously selected player names."""
226
+ if not isinstance(value, list):
227
+ value = []
228
+
229
+ validated = []
230
+ seen = set()
231
+ for name in value:
232
+ if not isinstance(name, str):
233
+ continue
234
+
235
+ trimmed = name.strip()
236
+ if not trimmed:
237
+ continue
238
+
239
+ # Case-insensitive deduplication
240
+ key = trimmed.lower()
241
+ if key not in seen:
242
+ seen.add(key)
243
+ validated.append(trimmed)
244
+
245
+ self.set("import_selected_player_names", validated)
246
+
247
+ @property
248
+ def max_mcq_options(self) -> int:
249
+ """Get the maximum number of MCQ options to display."""
250
+ return self._settings.get("max_mcq_options", 5)
251
+
252
+ @max_mcq_options.setter
253
+ def max_mcq_options(self, value: int) -> None:
254
+ """Set the maximum number of MCQ options to display."""
255
+ if value < 2 or value > 10:
256
+ raise ValueError("max_mcq_options must be between 2 and 10")
257
+ self.set("max_mcq_options", value)
258
+
259
+ @property
260
+ def check_for_updates(self) -> bool:
261
+ """Get whether to check for updates on startup."""
262
+ return self._settings.get("check_for_updates", True)
263
+
264
+ @check_for_updates.setter
265
+ def check_for_updates(self, value: bool) -> None:
266
+ """Set whether to check for updates on startup."""
267
+ self.set("check_for_updates", value)
268
+
269
+ @property
270
+ def last_update_check(self) -> Optional[str]:
271
+ """Get the timestamp of the last update check (ISO format)."""
272
+ return self._settings.get("last_update_check", None)
273
+
274
+ @last_update_check.setter
275
+ def last_update_check(self, value: Optional[str]) -> None:
276
+ """Set the timestamp of the last update check (ISO format)."""
277
+ self.set("last_update_check", value)
278
+
279
+ @property
280
+ def snooze_update_until(self) -> Optional[str]:
281
+ """Get the timestamp to snooze update notifications until (ISO format)."""
282
+ return self._settings.get("snooze_update_until", None)
283
+
284
+ @snooze_update_until.setter
285
+ def snooze_update_until(self, value: Optional[str]) -> None:
286
+ """Set the timestamp to snooze update notifications until (ISO format)."""
287
+ self.set("snooze_update_until", value)
288
+
289
+ def is_gnubg_available(self) -> bool:
290
+ """
291
+ Check if GnuBG is configured and accessible.
292
+
293
+ Returns:
294
+ True if gnubg_path is set and the file exists and is executable.
295
+ """
296
+ path = self.gnubg_path
297
+ if path is None:
298
+ return False
299
+ try:
300
+ path_obj = Path(path)
301
+ return path_obj.exists() and os.access(path, os.X_OK)
302
+ except (OSError, ValueError):
303
+ return False
304
+
305
+
306
+ # Global settings instance
307
+ _settings = None
308
+
309
+
310
+ def get_settings() -> Settings:
311
+ """Get the global settings instance."""
312
+ global _settings
313
+ if _settings is None:
314
+ _settings = Settings()
315
+ return _settings
@@ -0,0 +1,7 @@
1
+ """
2
+ Third-party code with different licenses.
3
+
4
+ This directory contains code from external projects that use licenses
5
+ other than the main project license (MIT). Each subdirectory has its
6
+ own LICENSE file.
7
+ """
@@ -0,0 +1,17 @@
1
+ """
2
+ xgdatatools - eXtreme Gammon binary file parser
3
+
4
+ Original author: Michael Petch <mpetch@gnubg.org>
5
+ License: LGPL-3.0 or later
6
+ Source: https://github.com/oysteijo/xgdatatools
7
+
8
+ This package contains the xgdatatools library for parsing eXtreme Gammon
9
+ binary (.xg) file formats.
10
+ """
11
+
12
+ from . import xgimport
13
+ from . import xgstruct
14
+ from . import xgutils
15
+ from . import xgzarc
16
+
17
+ __all__ = ['xgimport', 'xgstruct', 'xgutils', 'xgzarc']
@@ -0,0 +1,160 @@
1
+ #
2
+ # xgimport.py - XG import module
3
+ # Copyright (C) 2013,2014 Michael Petch <mpetch@gnubg.org>
4
+ # <mpetch@capp-sysware.com>
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ #
20
+
21
+ from __future__ import with_statement as _with
22
+ import tempfile as _tempfile
23
+ import shutil as _shutil
24
+ import struct as _struct
25
+ import os as _os
26
+ from . import xgutils as _xgutils
27
+ from . import xgzarc as _xgzarc
28
+ from . import xgstruct as _xgstruct
29
+
30
+
31
+ class Import(object):
32
+
33
+ class Segment(object):
34
+ GDF_HDR, GDF_IMAGE, XG_GAMEHDR, XG_GAMEFILE, XG_ROLLOUTS, XG_COMMENT, \
35
+ ZLIBARC_IDX, XG_UNKNOWN = range(8)
36
+ EXTENSIONS = ['_gdh.bin', '.jpg', '_gamehdr.bin', '_gamefile.bin',
37
+ '_rollouts.bin', '_comments.bin', '_idx.bin', None]
38
+ GDF_HDR_EXT, GDF_IMAGE_EXT, XG_GAMEHDR_EXT, XG_GAMEFILE_EXT, \
39
+ XG_ROLLOUTS_EXT, XG_COMMENTS_EXT, \
40
+ XG_IDX_EXT, XG_UNKNOWN = EXTENSIONS
41
+ XG_FILEMAP = {'temp.xgi': XG_GAMEHDR, 'temp.xgr': XG_ROLLOUTS,
42
+ 'temp.xgc': XG_COMMENT, 'temp.xg': XG_GAMEFILE}
43
+
44
+ XG_GAMEHDR_LEN = 556
45
+
46
+ __TMP_PREFIX = 'tmpXGI'
47
+
48
+ def __init__(self, type=GDF_HDR, delete=True, prefix=__TMP_PREFIX):
49
+ self.filename = None
50
+ self.fd = None
51
+ self.file = None
52
+ self.type = type
53
+ self.__prefix = prefix
54
+ self.__autodelete = delete
55
+ self.ext = self.EXTENSIONS[type]
56
+
57
+ def __enter__(self):
58
+ self.createtempfile()
59
+ return self
60
+
61
+ def __exit__(self, type, value, traceback):
62
+ self.closetempfile()
63
+
64
+ def closetempfile(self):
65
+ try:
66
+ if self.file is not None:
67
+ self.file.close()
68
+ finally:
69
+ self.fd = None
70
+ self.file = None
71
+ if self.__autodelete and self.filename is not None and \
72
+ _os.path.exists(self.filename):
73
+ try:
74
+ _os.unlink(self.filename)
75
+ finally:
76
+ self.filename = None
77
+
78
+ def copyto(self, fileto):
79
+ _shutil.copy(self.filename, fileto)
80
+
81
+ def createtempfile(self, mode="w+b"):
82
+ self.fd, self.filename = _tempfile.mkstemp(prefix=self.__prefix)
83
+ self.file = _os.fdopen(self.fd, mode)
84
+ return self
85
+
86
+ def __init__(self, filename):
87
+ self.filename = filename
88
+
89
+ def getfilesegment(self):
90
+ with open(self.filename, "rb") as xginfile:
91
+ # Extract the uncompressed Game Data Header (GDH)
92
+ # Note: MS Windows Vista feature
93
+ gdfheader = \
94
+ _xgstruct.GameDataFormatHdrRecord().fromstream(xginfile)
95
+ if gdfheader is None:
96
+ raise Error("Not a game data format file", self.filename)
97
+
98
+ # Extract the Game Format Header to a temporary file
99
+ with Import.Segment(type=Import.Segment.GDF_HDR) as segment:
100
+ xginfile.seek(0)
101
+ block = xginfile.read(gdfheader.HeaderSize)
102
+ segment.file.write(block)
103
+ segment.file.flush()
104
+ yield segment
105
+
106
+ # Extract the uncompressed thumbnail JPEG from the GDF hdr
107
+ if (gdfheader.ThumbnailSize > 0):
108
+ with Import.Segment(type=Import.Segment.GDF_IMAGE) as segment:
109
+ xginfile.seek(gdfheader.ThumbnailOffset, _os.SEEK_CUR)
110
+ imgbuf = xginfile.read(gdfheader.ThumbnailSize)
111
+ segment.file.write(imgbuf)
112
+ segment.file.flush()
113
+ yield segment
114
+
115
+ # Retrieve an archive object from the stream
116
+ archiveobj = _xgzarc.ZlibArchive(xginfile)
117
+
118
+ # Process all the files in the archive
119
+ for filerec in archiveobj.arcregistry:
120
+
121
+ # Retrieve the archive file to a temporary file
122
+ segment_file, seg_filename = archiveobj.getarchivefile(filerec)
123
+
124
+ # Create a file segment object to passback to the caller
125
+ xg_filetype = Import.Segment.XG_FILEMAP[filerec.name]
126
+ xg_filesegment = Import.Segment(type=xg_filetype,
127
+ delete=False)
128
+ xg_filesegment.filename = seg_filename
129
+ xg_filesegment.fd = segment_file
130
+
131
+ # If we are looking at the game info file then check
132
+ # the magic number to ensure it is valid
133
+ if xg_filetype == Import.Segment.XG_GAMEFILE:
134
+ segment_file.seek(Import.Segment.XG_GAMEHDR_LEN)
135
+ magicStr = bytearray(segment_file.read(4)).decode('ascii')
136
+ if magicStr != 'DMLI':
137
+ raise Error("Not a valid XG gamefile", self.filename)
138
+
139
+ yield xg_filesegment
140
+
141
+ segment_file.close()
142
+ _os.unlink(seg_filename)
143
+
144
+ return
145
+
146
+
147
+ class Error(Exception):
148
+
149
+ def __init__(self, error, filename):
150
+ self.value = "XG Import Error processing '%s': %s" % \
151
+ (filename, str(error))
152
+ self.error = error
153
+ self.filename = filename
154
+
155
+ def __str__(self):
156
+ return repr(self.value)
157
+
158
+
159
+ if __name__ == '__main__':
160
+ pass