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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/* Modern Dark Theme for AnkiGammon GUI - Inspired by Catppuccin Mocha */
|
|
2
|
+
|
|
3
|
+
/* Main Window and Dialogs */
|
|
4
|
+
QMainWindow, QDialog, QWidget {
|
|
5
|
+
background-color: #1e1e2e;
|
|
6
|
+
color: #cdd6f4;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Primary Buttons */
|
|
10
|
+
QPushButton {
|
|
11
|
+
background-color: #89b4fa;
|
|
12
|
+
color: #1e1e2e;
|
|
13
|
+
border: none;
|
|
14
|
+
padding: 10px 18px;
|
|
15
|
+
border-radius: 6px;
|
|
16
|
+
font-weight: 600;
|
|
17
|
+
font-size: 13px;
|
|
18
|
+
min-height: 28px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
QPushButton:hover {
|
|
22
|
+
background-color: #a0c8fc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
QPushButton:pressed {
|
|
26
|
+
background-color: #74c7ec;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
QPushButton:disabled {
|
|
30
|
+
background-color: #313244;
|
|
31
|
+
color: #6c7086;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Settings and Secondary Buttons */
|
|
35
|
+
QPushButton#btn_settings {
|
|
36
|
+
background-color: transparent;
|
|
37
|
+
color: #cdd6f4;
|
|
38
|
+
border: 2px solid #45475a;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
QPushButton#btn_settings:hover {
|
|
42
|
+
background-color: rgba(137, 180, 250, 0.12);
|
|
43
|
+
color: #f5e0dc;
|
|
44
|
+
border-color: #89b4fa;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
QPushButton#btn_settings:pressed {
|
|
48
|
+
background-color: rgba(137, 180, 250, 0.18);
|
|
49
|
+
color: #f5e0dc;
|
|
50
|
+
border-color: #89b4fa;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Position List Widget */
|
|
54
|
+
QListWidget {
|
|
55
|
+
background-color: #181825;
|
|
56
|
+
border: 2px solid #313244;
|
|
57
|
+
border-radius: 8px;
|
|
58
|
+
padding: 6px;
|
|
59
|
+
outline: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
QListWidget::item {
|
|
63
|
+
padding: 12px 10px;
|
|
64
|
+
border-radius: 6px;
|
|
65
|
+
margin: 2px 0px;
|
|
66
|
+
color: #cdd6f4;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
QListWidget::item:selected {
|
|
70
|
+
background-color: #89b4fa;
|
|
71
|
+
color: #1e1e2e;
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
QListWidget::item:hover {
|
|
76
|
+
background-color: #313244;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
QListWidget::item:selected:hover {
|
|
80
|
+
background-color: #a0c8fc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Group Boxes */
|
|
84
|
+
QGroupBox {
|
|
85
|
+
border: 2px solid #45475a;
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
margin-top: 16px;
|
|
88
|
+
font-weight: 700;
|
|
89
|
+
padding-top: 16px;
|
|
90
|
+
color: #f5e0dc;
|
|
91
|
+
font-size: 13px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
QGroupBox::title {
|
|
95
|
+
subcontrol-origin: margin;
|
|
96
|
+
subcontrol-position: top left;
|
|
97
|
+
padding: 4px 12px;
|
|
98
|
+
background-color: #1e1e2e;
|
|
99
|
+
border-radius: 4px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Labels */
|
|
103
|
+
QLabel {
|
|
104
|
+
color: #cdd6f4;
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
QLabel#title {
|
|
109
|
+
color: #f5e0dc;
|
|
110
|
+
font-size: 16px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Input Fields */
|
|
114
|
+
QLineEdit, QComboBox {
|
|
115
|
+
padding: 8px 12px;
|
|
116
|
+
border: 2px solid #45475a;
|
|
117
|
+
border-radius: 6px;
|
|
118
|
+
background-color: #181825;
|
|
119
|
+
color: #cdd6f4;
|
|
120
|
+
selection-background-color: #89b4fa;
|
|
121
|
+
selection-color: #1e1e2e;
|
|
122
|
+
font-size: 13px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
QLineEdit:focus, QComboBox:focus {
|
|
126
|
+
border: 2px solid #89b4fa;
|
|
127
|
+
background-color: #1e1e2e;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
QComboBox:hover {
|
|
131
|
+
border-color: #6c7086;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
QComboBox:disabled {
|
|
135
|
+
background-color: #313244;
|
|
136
|
+
color: #6c7086;
|
|
137
|
+
border-color: #313244;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
QComboBox::drop-down {
|
|
141
|
+
border: none;
|
|
142
|
+
padding-right: 8px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
QComboBox::down-arrow {
|
|
146
|
+
image: url(ankigammon/gui/resources/down-arrow.svg);
|
|
147
|
+
width: 12px;
|
|
148
|
+
height: 8px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
QComboBox:disabled::down-arrow {
|
|
152
|
+
opacity: 0.3;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
QComboBox QAbstractItemView {
|
|
156
|
+
background-color: #181825;
|
|
157
|
+
border: 2px solid #45475a;
|
|
158
|
+
selection-background-color: #89b4fa;
|
|
159
|
+
selection-color: #1e1e2e;
|
|
160
|
+
color: #cdd6f4;
|
|
161
|
+
padding: 4px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Checkboxes */
|
|
165
|
+
QCheckBox {
|
|
166
|
+
spacing: 10px;
|
|
167
|
+
color: #cdd6f4;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
QCheckBox::indicator {
|
|
171
|
+
width: 20px;
|
|
172
|
+
height: 20px;
|
|
173
|
+
border-radius: 4px;
|
|
174
|
+
border: 3px solid #45475a;
|
|
175
|
+
background-color: #181825;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
QCheckBox::indicator:hover {
|
|
179
|
+
border-color: #89b4fa;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
QCheckBox::indicator:checked {
|
|
183
|
+
background-color: #89b4fa;
|
|
184
|
+
border: 3px solid #45475a;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
QCheckBox::indicator:checked:hover {
|
|
188
|
+
background-color: #a0c8fc;
|
|
189
|
+
border-color: #89b4fa;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Radio Buttons */
|
|
193
|
+
QRadioButton {
|
|
194
|
+
spacing: 10px;
|
|
195
|
+
color: #cdd6f4;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
QRadioButton::indicator {
|
|
199
|
+
width: 20px;
|
|
200
|
+
height: 20px;
|
|
201
|
+
border-radius: 10px;
|
|
202
|
+
border: 2px solid #45475a;
|
|
203
|
+
background-color: #181825;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
QRadioButton::indicator:hover {
|
|
207
|
+
border-color: #89b4fa;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
QRadioButton::indicator:checked {
|
|
211
|
+
background-color: #ffffff;
|
|
212
|
+
border: 5px solid #89b4fa;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* Progress Bar */
|
|
216
|
+
QProgressBar {
|
|
217
|
+
border: 2px solid #45475a;
|
|
218
|
+
border-radius: 6px;
|
|
219
|
+
text-align: center;
|
|
220
|
+
background-color: #181825;
|
|
221
|
+
color: #ffffff;
|
|
222
|
+
font-weight: 700;
|
|
223
|
+
height: 24px;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
QProgressBar::chunk {
|
|
227
|
+
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
228
|
+
stop:0 #89b4fa, stop:1 #74c7ec);
|
|
229
|
+
border-radius: 4px;
|
|
230
|
+
margin: 2px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* Text Edit */
|
|
234
|
+
QTextEdit {
|
|
235
|
+
background-color: #181825;
|
|
236
|
+
border: 2px solid #45475a;
|
|
237
|
+
border-radius: 6px;
|
|
238
|
+
padding: 10px;
|
|
239
|
+
color: #cdd6f4;
|
|
240
|
+
selection-background-color: #89b4fa;
|
|
241
|
+
selection-color: #1e1e2e;
|
|
242
|
+
font-family: 'Consolas', 'Monaco', monospace;
|
|
243
|
+
font-size: 12px;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* Status Bar */
|
|
247
|
+
QStatusBar {
|
|
248
|
+
background-color: #181825;
|
|
249
|
+
color: #a6adc8;
|
|
250
|
+
border-top: 1px solid #313244;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
QStatusBar::item {
|
|
254
|
+
border: none;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* Menu Bar */
|
|
258
|
+
QMenuBar {
|
|
259
|
+
background-color: #181825;
|
|
260
|
+
color: #cdd6f4;
|
|
261
|
+
border-bottom: 2px solid #313244;
|
|
262
|
+
padding: 4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
QMenuBar::item {
|
|
266
|
+
padding: 6px 14px;
|
|
267
|
+
border-radius: 4px;
|
|
268
|
+
background-color: transparent;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
QMenuBar::item:selected {
|
|
272
|
+
background-color: #313244;
|
|
273
|
+
color: #f5e0dc;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
QMenuBar::item:pressed {
|
|
277
|
+
background-color: #45475a;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Menus */
|
|
281
|
+
QMenu {
|
|
282
|
+
background-color: #181825;
|
|
283
|
+
border: 2px solid #313244;
|
|
284
|
+
border-radius: 8px;
|
|
285
|
+
padding: 6px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
QMenu::item {
|
|
289
|
+
padding: 8px 32px 8px 16px;
|
|
290
|
+
border-radius: 4px;
|
|
291
|
+
color: #cdd6f4;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
QMenu::item:selected {
|
|
295
|
+
background-color: #89b4fa;
|
|
296
|
+
color: #1e1e2e;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
QMenu::separator {
|
|
300
|
+
height: 2px;
|
|
301
|
+
background-color: #313244;
|
|
302
|
+
margin: 6px 10px;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
QMenu::icon {
|
|
306
|
+
padding-left: 10px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* Dialog Button Box */
|
|
310
|
+
QDialogButtonBox QPushButton {
|
|
311
|
+
min-width: 90px;
|
|
312
|
+
padding: 8px 16px;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* Scrollbars */
|
|
316
|
+
QScrollBar:vertical {
|
|
317
|
+
border: none;
|
|
318
|
+
background-color: #181825;
|
|
319
|
+
width: 12px;
|
|
320
|
+
border-radius: 6px;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
QScrollBar::handle:vertical {
|
|
324
|
+
background-color: #45475a;
|
|
325
|
+
border-radius: 6px;
|
|
326
|
+
min-height: 30px;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
QScrollBar::handle:vertical:hover {
|
|
330
|
+
background-color: #585b70;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
|
334
|
+
height: 0px;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
QScrollBar:horizontal {
|
|
338
|
+
border: none;
|
|
339
|
+
background-color: #181825;
|
|
340
|
+
height: 12px;
|
|
341
|
+
border-radius: 6px;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
QScrollBar::handle:horizontal {
|
|
345
|
+
background-color: #45475a;
|
|
346
|
+
border-radius: 6px;
|
|
347
|
+
min-width: 30px;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
QScrollBar::handle:horizontal:hover {
|
|
351
|
+
background-color: #585b70;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
|
355
|
+
width: 0px;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Tooltips */
|
|
359
|
+
QToolTip {
|
|
360
|
+
background-color: #313244;
|
|
361
|
+
color: #cdd6f4;
|
|
362
|
+
border: 2px solid #45475a;
|
|
363
|
+
border-radius: 6px;
|
|
364
|
+
padding: 8px;
|
|
365
|
+
font-size: 12px;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* Message Boxes */
|
|
369
|
+
QMessageBox {
|
|
370
|
+
background-color: #1e1e2e;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
QMessageBox QLabel {
|
|
374
|
+
color: #cdd6f4;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* Tab Widgets (for future use) */
|
|
378
|
+
QTabWidget::pane {
|
|
379
|
+
border: 2px solid #313244;
|
|
380
|
+
border-radius: 8px;
|
|
381
|
+
background-color: #181825;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
QTabBar::tab {
|
|
385
|
+
background-color: #313244;
|
|
386
|
+
color: #a6adc8;
|
|
387
|
+
padding: 10px 20px;
|
|
388
|
+
border-top-left-radius: 6px;
|
|
389
|
+
border-top-right-radius: 6px;
|
|
390
|
+
margin-right: 2px;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
QTabBar::tab:selected {
|
|
394
|
+
background-color: #89b4fa;
|
|
395
|
+
color: #1e1e2e;
|
|
396
|
+
font-weight: 600;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
QTabBar::tab:hover {
|
|
400
|
+
background-color: #45475a;
|
|
401
|
+
color: #cdd6f4;
|
|
402
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource path utilities for GUI.
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_resource_path(relative_path: str) -> Path:
|
|
9
|
+
"""
|
|
10
|
+
Get absolute path to resource, works for dev and PyInstaller.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
relative_path: Relative path to resource (e.g., "gui/resources/icon.png")
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Path: Absolute path to resource
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
# PyInstaller creates a temp folder and stores path in _MEIPASS
|
|
20
|
+
base_path = Path(sys._MEIPASS)
|
|
21
|
+
except AttributeError:
|
|
22
|
+
# Running in normal Python environment
|
|
23
|
+
# __file__ is in ankigammon/gui/resources.py, so parent.parent gets us to repo root
|
|
24
|
+
base_path = Path(__file__).parent.parent.parent
|
|
25
|
+
|
|
26
|
+
return base_path / relative_path
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Version update checker using GitHub Releases API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QThread, Signal
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# GitHub repository info
|
|
16
|
+
REPO_OWNER = "Deinonychus999"
|
|
17
|
+
REPO_NAME = "AnkiGammon"
|
|
18
|
+
GITHUB_API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
|
|
19
|
+
GITHUB_RELEASES_URL = f"https://github.com/{REPO_OWNER}/{REPO_NAME}/releases"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VersionChecker:
|
|
23
|
+
"""Check for updates via GitHub Releases API."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, timeout: int = 5):
|
|
26
|
+
"""Initialize version checker.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
timeout: Request timeout in seconds
|
|
30
|
+
"""
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
def check_latest_version(self) -> Optional[Dict]:
|
|
34
|
+
"""Fetch latest release from GitHub API.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dict with release info or None if failed:
|
|
38
|
+
{
|
|
39
|
+
'version': '1.0.6',
|
|
40
|
+
'name': 'Version 1.0.6',
|
|
41
|
+
'release_notes': '...',
|
|
42
|
+
'download_url': 'https://...',
|
|
43
|
+
'published_at': '2024-01-15T10:30:00Z'
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
import requests
|
|
48
|
+
except ImportError:
|
|
49
|
+
logger.warning("requests library not available, cannot check for updates")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
response = requests.get(
|
|
54
|
+
GITHUB_API_URL,
|
|
55
|
+
timeout=(self.timeout, self.timeout),
|
|
56
|
+
headers={'Accept': 'application/vnd.github+json'}
|
|
57
|
+
)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
|
|
60
|
+
data = response.json()
|
|
61
|
+
|
|
62
|
+
# Skip pre-releases and drafts
|
|
63
|
+
if data.get('prerelease') or data.get('draft'):
|
|
64
|
+
logger.info("Skipping pre-release or draft")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
tag = data.get('tag_name', '').lstrip('v')
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
'version': tag,
|
|
71
|
+
'name': data.get('name', tag),
|
|
72
|
+
'release_notes': data.get('body', ''),
|
|
73
|
+
'download_url': self._extract_download_url(data),
|
|
74
|
+
'published_at': data.get('published_at', ''),
|
|
75
|
+
'html_url': data.get('html_url', GITHUB_RELEASES_URL)
|
|
76
|
+
}
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(f"Failed to check for updates: {e}")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def _extract_download_url(self, release_data: Dict) -> str:
|
|
82
|
+
"""Extract appropriate download URL from release data.
|
|
83
|
+
|
|
84
|
+
Uses GitHub's /releases/latest/download/ URL pattern for direct downloads.
|
|
85
|
+
"""
|
|
86
|
+
# Determine platform-specific filename
|
|
87
|
+
if sys.platform == 'win32':
|
|
88
|
+
# Windows: ankigammon-windows.zip
|
|
89
|
+
filename = 'ankigammon-windows.zip'
|
|
90
|
+
elif sys.platform == 'darwin':
|
|
91
|
+
# macOS: AnkiGammon-macOS.dmg
|
|
92
|
+
filename = 'AnkiGammon-macOS.dmg'
|
|
93
|
+
else:
|
|
94
|
+
# Linux: AnkiGammon-x86_64.AppImage
|
|
95
|
+
filename = 'AnkiGammon-x86_64.AppImage'
|
|
96
|
+
|
|
97
|
+
# Construct direct download URL using GitHub's /releases/latest/download/ pattern
|
|
98
|
+
download_url = f"https://github.com/{REPO_OWNER}/{REPO_NAME}/releases/latest/download/{filename}"
|
|
99
|
+
|
|
100
|
+
return download_url
|
|
101
|
+
|
|
102
|
+
def compare_versions(self, current: str, latest: str) -> bool:
|
|
103
|
+
"""Check if latest version is newer than current.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
current: Current version string (e.g., "1.0.6")
|
|
107
|
+
latest: Latest version string
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if update is available
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
from packaging.version import Version
|
|
114
|
+
return Version(latest) > Version(current)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning(f"Failed to compare versions: {e}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class VersionCheckCache:
|
|
121
|
+
"""Manages version check caching to avoid excessive API calls."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, cache_dir: Optional[Path] = None):
|
|
124
|
+
"""Initialize cache manager.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
cache_dir: Directory for cache file (defaults to ~/.ankigammon)
|
|
128
|
+
"""
|
|
129
|
+
if cache_dir is None:
|
|
130
|
+
cache_dir = Path.home() / '.ankigammon'
|
|
131
|
+
self.cache_file = cache_dir / 'version_check.json'
|
|
132
|
+
self.cache_dir = cache_dir
|
|
133
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
def should_check(self, min_hours_between_checks: int = 24) -> bool:
|
|
136
|
+
"""Check if enough time has passed since last check.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
min_hours_between_checks: Minimum hours between checks
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if should check now
|
|
143
|
+
"""
|
|
144
|
+
if not self.cache_file.exists():
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
with open(self.cache_file, 'r') as f:
|
|
149
|
+
cache = json.load(f)
|
|
150
|
+
|
|
151
|
+
last_check = datetime.fromisoformat(cache.get('last_check', ''))
|
|
152
|
+
time_since_check = datetime.now() - last_check
|
|
153
|
+
|
|
154
|
+
return time_since_check > timedelta(hours=min_hours_between_checks)
|
|
155
|
+
except (json.JSONDecodeError, ValueError, KeyError, OSError):
|
|
156
|
+
return True # If cache is corrupted, check anyway
|
|
157
|
+
|
|
158
|
+
def get_cached_update(self) -> Optional[Dict]:
|
|
159
|
+
"""Get previously cached update info.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Cached release info or None
|
|
163
|
+
"""
|
|
164
|
+
if not self.cache_file.exists():
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
with open(self.cache_file, 'r') as f:
|
|
169
|
+
cache = json.load(f)
|
|
170
|
+
return cache.get('latest_release')
|
|
171
|
+
except (json.JSONDecodeError, KeyError, OSError):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def save_check(self, release_info: Optional[Dict]):
|
|
175
|
+
"""Save the result of a version check.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
release_info: Release info dict or None
|
|
179
|
+
"""
|
|
180
|
+
cache = {
|
|
181
|
+
'last_check': datetime.now().isoformat(),
|
|
182
|
+
'latest_release': release_info
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
with open(self.cache_file, 'w') as f:
|
|
187
|
+
json.dump(cache, f, indent=2)
|
|
188
|
+
except OSError as e:
|
|
189
|
+
logger.warning(f"Failed to save version check cache: {e}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class VersionCheckerThread(QThread):
|
|
193
|
+
"""Background thread for non-blocking version checking."""
|
|
194
|
+
|
|
195
|
+
# Signals
|
|
196
|
+
update_available = Signal(dict) # Emitted when update found
|
|
197
|
+
check_complete = Signal() # Emitted when check done
|
|
198
|
+
check_failed = Signal() # Emitted when check failed (network error)
|
|
199
|
+
|
|
200
|
+
def __init__(self, current_version: str, force_check: bool = False):
|
|
201
|
+
"""Initialize checker thread.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
current_version: Current app version
|
|
205
|
+
force_check: If True, bypass cache and check immediately
|
|
206
|
+
"""
|
|
207
|
+
super().__init__()
|
|
208
|
+
self.current_version = current_version
|
|
209
|
+
self.force_check = force_check
|
|
210
|
+
self.checker = VersionChecker()
|
|
211
|
+
self.cache = VersionCheckCache()
|
|
212
|
+
|
|
213
|
+
def run(self):
|
|
214
|
+
"""Execute version check in background thread."""
|
|
215
|
+
try:
|
|
216
|
+
# Check if we should skip (unless forced)
|
|
217
|
+
if not self.force_check and not self.cache.should_check(min_hours_between_checks=24):
|
|
218
|
+
logger.info("Skipping version check (too recent)")
|
|
219
|
+
cached = self.cache.get_cached_update()
|
|
220
|
+
if cached:
|
|
221
|
+
self._check_and_emit(cached)
|
|
222
|
+
self.check_complete.emit()
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Fetch from GitHub
|
|
226
|
+
logger.info("Checking for updates...")
|
|
227
|
+
latest = self.checker.check_latest_version()
|
|
228
|
+
|
|
229
|
+
if latest:
|
|
230
|
+
# Cache the result
|
|
231
|
+
self.cache.save_check(latest)
|
|
232
|
+
self._check_and_emit(latest)
|
|
233
|
+
else:
|
|
234
|
+
# Network failure - try cached value
|
|
235
|
+
logger.info("Network check failed, using cache")
|
|
236
|
+
cached = self.cache.get_cached_update()
|
|
237
|
+
if cached:
|
|
238
|
+
self._check_and_emit(cached)
|
|
239
|
+
elif self.force_check:
|
|
240
|
+
# Manual check with no network and no cache = fail
|
|
241
|
+
self._check_failed = True
|
|
242
|
+
self.check_failed.emit()
|
|
243
|
+
|
|
244
|
+
self.check_complete.emit()
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"Version check thread error: {e}")
|
|
247
|
+
self.check_complete.emit()
|
|
248
|
+
|
|
249
|
+
def _check_and_emit(self, release_info: Dict):
|
|
250
|
+
"""Check if update is available and emit signal.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
release_info: Release information dict
|
|
254
|
+
"""
|
|
255
|
+
if self.checker.compare_versions(self.current_version, release_info['version']):
|
|
256
|
+
logger.info(f"Update available: {release_info['version']}")
|
|
257
|
+
self.update_available.emit(release_info)
|
|
258
|
+
else:
|
|
259
|
+
logger.info(f"Already up to date ({self.current_version})")
|