forked from Aquafina-water-bottle/jpmn-manager
-
Notifications
You must be signed in to change notification settings - Fork 0
/
__init__.py
442 lines (342 loc) · 15.3 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
from __future__ import annotations
import os
import sys
import time
import json
import shlex
import inspect
import argparse
from typing import Callable, Any
from aqt import mw, gui_hooks
from aqt.qt import Qt, QMessageBox, QCheckBox, qconnect, QAction
# from aqt.utils import show_info, ask_user_dialog # TODO: use these when 2.1.60 becomes the min supported version
from aqt.utils import showInfo, askUserDialog, getText, ButtonedDialog, tooltip
from aqt.operations import QueryOp
from .kanji import get_kanji_data, KanjiData
# https://stackoverflow.com/a/11158224
script_file = inspect.getfile(inspect.currentframe())
base_folder = os.path.dirname(os.path.abspath(script_file))
sys.path.append(os.path.join(base_folder, "tools"))
# import as if this script was under tools/
import utils as jpmn_utils
import install as jpmn_install
import batch as jpmn_batch
import version as jpmn_version
# TODO change to not be using prerelease version
# SETUP_CHANGES_URL = "https://arbyste.github.io/jp-mining-note/setupchanges/"
SETUP_CHANGES_URL = (
"https://arbyste.github.io/jp-mining-note-prerelease/setupchanges/"
)
UPDATING_URL = (
"https://arbyste.github.io/jp-mining-note-prerelease/updating/"
)
def _get_collection():
collection = mw.col
if collection is None:
raise Exception("collection is not available")
return collection
def _get_database():
database = _get_collection().db
if database is None:
raise Exception("database is not available")
return database
def _get_version_from_anki(
error=False, fallback_to_ankiconnect=True
) -> jpmn_version.Version | None:
"""
gets jpmn version from Anki, if installed.
"""
MODEL_NAME = "JP Mining Note"
model = _get_collection().models.byName(MODEL_NAME)
if model is None:
if error:
raise Exception("model was not found: {}".format(MODEL_NAME))
return None
# checks all sides before erroring
for template in model["tmpls"]:
for side in [template["qfmt"], template["afmt"]]: # front, back
jpmn_version_str = jpmn_utils.get_version_from_template_side(
side, error=error
)
if jpmn_version_str is not None:
return jpmn_version.Version.from_str(jpmn_version_str)
if fallback_to_ankiconnect:
print("Falling back to jpmn_utils.get_version_from_anki...")
jpmn_ver_anki = jpmn_utils.get_version_from_anki(MODEL_NAME)
return jpmn_version.Version.from_str(jpmn_ver_anki)
return None # explicit return
def check_updates():
"""
compares version in template to version.txt
"""
script_folder = os.path.dirname(os.path.abspath(__file__))
version_file_path = os.path.join(script_folder, "version.txt")
with open(version_file_path) as f:
latest = jpmn_version.Version.from_str(f.read())
def op_func():
# NOTE: This is put in a QueryOp call because without one, Anki seems to deadlock itself.
# I'm guessing it has to do with how we remain in the main thread, but Anki-Connect
# itself runs in the main thread.
return _get_version_from_anki()
def success_func(curr: jpmn_version.Version):
if curr is None:
msg = f"Could not find the jp-mining-note version. Is the note installed?"
elif latest.cmp(curr, check_prerelease=True) == 1: # latest > curr
msg = f'An update to jp-mining-note is available!<br>- Current version: {curr}<br>- Latest version: {latest}<br>See how to update the note <a href="{UPDATING_URL}">here</a>.'
else:
msg = f"jp-mining-note is up to date. No update is necessary."
showInfo(msg, textFormat="rich")
op = QueryOp(
parent=mw,
op=lambda _: op_func(),
success=lambda version: success_func(version),
)
msg = f"Querying Anki for the jp-mining-note version..."
op.with_progress(msg).run_in_background()
def get_args(
raw_args: str, *args: Callable[[argparse.ArgumentParser], None]
) -> argparse.Namespace:
parser = argparse.ArgumentParser()
for add_args_func in args:
add_args_func(parser)
try:
# we cannot rely on ArgumentParser(exit_on_error=False) to always throw an error
# instead of exiting, so we use this gross hack to ensure Anki doesn't shut down...
return parser.parse_args(args=shlex.split(raw_args))
except SystemExit:
raise RuntimeError("Error in arguments")
def install(update=False, args_str: str = ""):
"""
installs or updates the note
"""
args = get_args(args_str, jpmn_utils.add_args, jpmn_install.add_args)
# because backup/ will be deleted on every addon update
args.backup_folder = os.path.join("user_files", "backup")
if update:
args.update = True
args.dev_never_warn = True # prevents input() from being ran
args.dev_raise_anki_error = True # raises visible errors for Anki users to see, instead of silently returning
def install_op():
return jpmn_install.main(args)
def install_success(post_message):
msg = f"Successfully {'updated' if update else 'installed'} jp-mining-note!"
if post_message is not None:
msg += (
"<br><br>"
+ f'You\'re not finished yet! See the <a href="{SETUP_CHANGES_URL}">Setup Changes</a> page to update everything else.'
)
# show_info(msg, textFormat=Qt.TextFormat.RichText) # RichText to make html work
showInfo(msg, textFormat="rich") # RichText to make html work
op = QueryOp(
parent=mw,
op=lambda _: install_op(),
success=lambda post_message: install_success(post_message),
)
msg = f"{'Updating' if update else 'Installing'} jp-mining-note..."
op.with_progress(msg).run_in_background()
def install_custom_args():
(args_str, ret) = getText("Enter the installer arguments below.")
if ret == 0: # user cancelled
return
install(args_str=args_str)
def confirm_update_warning():
# warning_msg = ("Updating will override any changes you made to jp-mining-note! "
# "Please make a backup of your collection before continuing. "
# "If you already made a backup and are fine with losing any changes, "
# "press 'Ok'. Otherwise, please press 'cancel'.")
# buttons = [QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.Cancel]
# def callback(idx: int):
# if idx == 0: # okay
# install(update=True)
# ask_user_dialog(warning_msg, callback, buttons=buttons, default_button=1)
CANCEL = "Cancel"
UPDATE = "I backed up my collection. Update!"
warning_msg = inspect.cleandoc(
f"""
Updating will override any changes you made to jp-mining-note!
Even if you have not made any changes to jp-mining-note,
<a href="https://aquafina-water-bottle.github.io/jp-mining-note-prerelease/faq/#how-do-i-backup-my-anki-data">please make a backup of your collection</a> before continuing.
If you already made a backup and are fine with losing any changes,
press 'Update'. Otherwise, please press 'Cancel'.
"""
)
buttons = [UPDATE, CANCEL]
dialog = askUserDialog(warning_msg, buttons=buttons)
dialog.setDefault(0)
dialog.setTextFormat(Qt.TextFormat.RichText)
result = dialog.run()
if result == UPDATE:
install(update=True)
def run_batch():
"""
runs batch.py function
"""
(args_str, ret) = getText("Enter the batch command below.")
if ret == 0: # user cancelled
return
args_arr = shlex.split(args_str)
try:
# we cannot rely on ArgumentParser(exit_on_error=False) to always throw an error
# instead of exiting, so we use this gross hack to ensure Anki doesn't shut down...
args = jpmn_batch.get_args(jpmn_batch.PUBLIC_FUNCTIONS_ANKI, args=args_arr)
except SystemExit:
raise RuntimeError("Error in arguments")
if "func" in args:
def batch_op():
# code copied from batch main()
#time.sleep(1) # to ensure the popup is shown properly?
func_args = vars(args)
func = func_args.pop("func")
return func(**func_args)
# try:
# func_args = vars(args)
# func = func_args.pop("func")
# return func(**func_args)
# except Exception as e:
# return e
def batch_success(result):
msg = "Successfully ran batch command!"
if result is not None:
msg += "\n\nBatch command result:\n" + result
showInfo(msg)
op = QueryOp(
parent=mw,
op=lambda _: batch_op(),
success=lambda result: batch_success(result),
)
msg = f"Running batch script..."
op.with_progress(msg).run_in_background()
else:
showInfo("Cannot find batch function")
def new_due_query():
# TODO: how to get new cards from options?
# TODO: how to only get "JP Mining Note" cards????
new_cards_limit = 30 # TODO temporary integer
db = _get_database()
# https://github.com/ankidroid/Anki-Android/wiki/Database-Structure#cards
# type=0: new (not learning/relearning)
# queue=0: new (not suspended)
QUERY = "select id, due from cards where type=0 and queue=0"
db.all(QUERY)
def batch_op():
return db.all(QUERY)
def batch_success(result):
new_cards = sorted(result, key=lambda x: x[1])[0:new_cards_limit]
new_cards_ids = [x[1] for x in new_cards]
print(new_cards)
showInfo(",".join(str(x) for x in new_cards_ids))
op = QueryOp(
parent=mw,
op=lambda _: batch_op(),
success=lambda result: batch_success(result),
)
msg = f"Running batch script..."
op.with_progress(msg).run_in_background()
def init_gui():
menu = mw.form.menuTools.addMenu("JPMN Manager")
check_update_action = QAction("Check for note updates", mw)
qconnect(check_update_action.triggered, lambda: check_updates())
menu.addAction(check_update_action)
install_action = QAction("Install jp-mining-note", mw)
qconnect(install_action.triggered, lambda: install())
menu.addAction(install_action)
update_action = QAction("Update jp-mining-note", mw)
qconnect(update_action.triggered, lambda: confirm_update_warning())
menu.addAction(update_action)
install_args_action = QAction("Run installer with arguments", mw)
qconnect(install_args_action.triggered, lambda: install_custom_args())
menu.addAction(install_args_action)
batch_action = QAction("Run batch command", mw)
qconnect(batch_action.triggered, lambda: run_batch())
menu.addAction(batch_action)
# new_due_query_action = QAction("(cache.js) Get new due cards", mw)
# qconnect(new_due_query_action.triggered, lambda: new_due_query())
# menu.addAction(new_due_query_action)
def check_updates_popup(ignore_until_ver: jpmn_utils.Version | None):
script_folder = os.path.dirname(os.path.abspath(__file__))
version_file_path = os.path.join(script_folder, "version.txt")
with open(version_file_path) as f:
latest = jpmn_version.Version.from_str(f.read())
# should be safe to not put in a background op, since ankiconnect call isn't used
# calling a background op here seems to interfere with AJT Japanese
curr = _get_version_from_anki(fallback_to_ankiconnect=False)
if curr is None:
msg = "(startup) Could not find the jp-mining-note version. Is the note installed?"
print(msg)
elif latest.cmp(curr, check_prerelease=True) == 1: # latest > curr
# check if we ignore this
# we ignore the update if ignore_until_ver >= latest
if (
ignore_until_ver is not None
and ignore_until_ver.cmp(latest, check_prerelease=True) >= 0
): # ignore_until_ver >= current
msg = f"(startup) A jp-mining-note update is available! However, this update is ignored by the user.\nIgnored until: {ignore_until_ver}\nCurrent version: {curr}\nLatest version: {latest}"
print(msg)
else:
msg = f"An update to jp-mining-note is available!<br>- Current version: {curr}<br>- Latest version: {latest}<br>Selecting 'Okay' will not update the note. See how to update the note <a href=\"{UPDATING_URL}\">here</a>."
OKAY = "Okay"
SKIP = "Skip update"
NEVER_NOTIFY = "Never notify again"
buttons = [OKAY, SKIP, NEVER_NOTIFY]
bd = ButtonedDialog(msg, buttons, mw, title="JPMN Manager")
bd.setIcon(QMessageBox.Icon.Information)
bd.setTextFormat(Qt.TextFormat.RichText)
bd.setDefault(0)
# disables/ignores here
selection = bd.run()
if selection == SKIP:
config = mw.addonManager.getConfig(__name__)
config["check_update_ignore_until_ver"] = str(latest)
mw.addonManager.writeConfig(__name__, config)
tooltip(f"Notifications for {latest} will no longer show.")
elif selection == NEVER_NOTIFY:
config = mw.addonManager.getConfig(__name__)
config["check_update_on_startup"] = False
mw.addonManager.writeConfig(__name__, config)
tooltip("There will be no update notifications in the future.")
else:
msg = f"(startup) jp-mining-note is up to date. No update is necessary."
print(msg)
optional_popup_attempt_shown = False
def optional_popup():
# prevents this function from running more than once
global optional_popup_attempt_shown
if optional_popup_attempt_shown:
return
optional_popup_attempt_shown = True
config = mw.addonManager.getConfig(__name__)
check_update = config["check_update_on_startup"]
if check_update:
ignore_until_ver_str = config["check_update_ignore_until_ver"]
if ignore_until_ver_str is not None:
ignore_until_ver = jpmn_version.Version.from_str(ignore_until_ver_str)
else:
ignore_until_ver = None
check_updates_popup(ignore_until_ver)
def pycmd_result_error(error: str | dict[str, Any]):
# similar format to Anki-Connect version 6+
return (True, json.dumps({"result": None, "error": error}))
def pycmd_result_success(result: str | dict[str, Any]):
return (True, json.dumps({"result": result, "error": None}))
def handle_pycmd_message(
handled: tuple[bool, Any], message: str, context: Any
) -> tuple[bool, Any]:
# looks for message formatted as "JPMN#id#data"
if not message.startswith("JPMN#"):
return handled # some other command, pass it on
# message starts with JPMN#, meaning this is our message!
try:
_, id, data = message.split("#", 2)
if id == "get-kanji-data":
# data should simply be a kanji here
kanji_data = get_kanji_data(data)
return pycmd_result_success(kanji_data.json_repr())
raise RuntimeError(f"invalid id '{id}'")
except Exception as e:
return pycmd_result_error(repr(e))
init_gui()
# required for collection to be fully initialized
# TODO: work with versions 2.1.54 and under, when profile is not loaded yet.
# Alternatively, bump min version to 2.1.60???
gui_hooks.profile_did_open.append(optional_popup)
gui_hooks.webview_did_receive_js_message.append(handle_pycmd_message)