forked from Kagu-chan/anki.transfer-review-history
-
Notifications
You must be signed in to change notification settings - Fork 0
/
__init__.py
434 lines (336 loc) · 15.9 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
from typing import Optional, Callable
from aqt import gui_hooks, mw
from aqt.utils import tooltip, qconnect, showInfo
from aqt.browser import Browser
from aqt.qt import QAction, QMenu
from aqt.operations.scheduling import forget_cards
from anki.cards import Card
from anki.lang import _
from anki.utils import int_time
def truncateString(string, maxSize=17) -> str:
"""Truncate a string to a desired length, default 17"""
if len(string) > maxSize:
return string[0:maxSize - 1] + "..."
else:
return string
def run_on_configuration_change(function) -> None:
"""Add event listener to addon manager -> config updated"""
mw.addonManager.setConfigUpdatedAction(__name__, lambda *_: function())
class Config:
def __init__(self) -> None:
config = mw.addonManager.getConfig(__name__)
self._deleteSourceCard: bool = config["delete_source_card"]
self._mergeHistories: bool = config["merge_histories"]
self._targetDeck: str = config["target_deck"]
self._sourceDeck: str = config["source_deck"]
def save(self):
"""Persist configuration changes back to plugin"""
mw.addonManager.writeConfig(__name__, {
"delete_source_card": self._deleteSourceCard,
"merge_histories": self._mergeHistories
})
@property
def targetDeck(self) -> str:
"""
Wether or not to delete the original card after transferring the review history.
Automatically saves on update
"""
return self._targetDeck
@property
def sourceDeck(self) -> str:
"""
Wether or not to delete the original card after transferring the review history.
Automatically saves on update
"""
return self._sourceDeck
@property
def deleteSourceCard(self) -> bool:
"""
Wether or not to delete the original card after transferring the review history.
Automatically saves on update
"""
return self._deleteSourceCard
@deleteSourceCard.setter
def deleteSourceCard(self, value) -> None:
self._deleteSourceCard = value
self.save()
@property
def mergeHistories(self) -> bool:
"""
If true, review histories of two cards will be merged.
If false, review history of target crad will be wiped before update.
Automatically saves on update
"""
return self._mergeHistories
@mergeHistories.setter
def mergeHistories(self, value) -> None:
self._mergeHistories = value
self.save()
class ReviewHistory:
def __init__(self) -> None:
self.config = Config()
self.createPluginMenu()
self.browser: (Browser | None) = None
self.current: (Card | None) = None
self.menu: (QMenu | None) = None
self.selectAction: (QAction | None) = None
self.targetAction: (QAction | None) = None
self.copyAction: (QAction | None) = None
def initEvents(self) -> None:
"""
Listen to proper events in anki ui life cycle:
* browser_menus_did_init -> Create the menus
* browser_did_change_row -> Update the menus
"""
gui_hooks.browser_menus_did_init.append(self.createBrowserMenu)
gui_hooks.browser_did_change_row.append(lambda _: self.updateMenus())
def select(self) -> None:
"""Mark currently selected card in browser as selected"""
self.current = self.selected()
self.updateMenus()
tooltip("Selected review history for %d" % self.current.id)
def transferTo(self) -> None:
"""Transfer review history of previously selected card to current card. Deselect afterwards"""
self.transferReviewHistory(self.current, self.selected())
self.current = None
self.updateMenus()
def copyTo(self) -> None:
"""Copy review history of previously selected card to current card. Do not deselect afterwards"""
self.copyReviewHistory(self.current, self.selected())
def selected(self) -> Optional[Card]:
"""Return the currently selected card from browser, if only one is selected"""
return self.browser.table.get_single_selected_card()
def hasSelected(self) -> bool:
"""Check if only one card is selected in the browser"""
return bool(self.selected())
def updateMenus(self) -> None:
"""Enable or disable menu actions depending on current selection state"""
if (self.hasSelected()):
self.selectAction.setDisabled(
self.selected().ivl == 0 or (self.selected().id == self.current.id if self.current else False))
self.targetAction.setDisabled(self.selected().id == self.current.id if self.current else True)
self.copyAction.setDisabled(self.selected().id == self.current.id if self.current else True)
else:
self.selectAction.setDisabled(True)
self.targetAction.setDisabled(True)
self.copyAction.setDisabled(True)
self.menu.setDisabled(
not (self.selectAction.isEnabled() or self.targetAction.isEnabled() or self.copyAction.isEnabled()))
def createBrowserMenu(self, browser: Browser) -> None:
"""Create menus in browser and attach its actions"""
self.browser = browser
self.menu = QMenu("Transfer review history", self.browser)
self.selectAction = QAction("Select current selection for transfer", self.browser)
self.targetAction = QAction(self.getTransferTargetText(), self.browser)
self.copyAction = QAction(self.getCopyTargetText(), self.browser)
self.browser.form.menu_Cards.addMenu(self.menu)
self.menu.addAction(self.selectAction)
self.menu.addAction(self.targetAction)
self.menu.addAction(self.copyAction)
qconnect(self.selectAction.triggered, self.select)
qconnect(self.targetAction.triggered, self.transferTo)
qconnect(self.copyAction.triggered, self.copyTo)
self.updateMenus()
run_on_configuration_change(self.updateTargetText)
def updateTargetText(self) -> None:
"""Update target selection text according to delete or merge configuration"""
try:
self.targetAction.setText(self.getTransferTargetText())
self.copyAction.setText(self.getCopyTargetText())
except:
pass
def getTransferTargetText(self) -> str:
"""Return target selection text according to delete configuration"""
text: str = "Merge review histories into current selection" if self.config.mergeHistories else "Transfer review history to current selection"
if (self.config.deleteSourceCard):
text = text + " and delete source afterwards"
return text
def getCopyTargetText(self) -> str:
"""Return target selection text according to delete configuration"""
return "Copy review history into current selection (%s)" % (
"merge" if self.config.mergeHistories else "replace")
def createPluginMenu(self) -> None:
"""Create configuration menu in tools menu"""
deleteCardsOption = QAction("Delete cards after transferring review history", mw, checkable=True,
checked=self.config.deleteSourceCard)
mergeHistoriesOption = QAction("Merge cards review history", mw, checkable=True,
checked=self.config.mergeHistories)
menu = mw.form.menuTools.addMenu('Transfer review history')
menu.addAction(deleteCardsOption)
menu.addAction(mergeHistoriesOption)
qconnect(deleteCardsOption.triggered, lambda v: self.updateConfig('deleteSourceCard', v))
qconnect(mergeHistoriesOption.triggered, lambda v: self.updateConfig('mergeHistories', v))
def updateConfig(self, key: str, value: bool) -> None:
"""Update delete card configuration"""
setattr(self.config, key, value)
if (self.menu):
self.updateTargetText()
def transferReviewHistory(self, fromCard: Card, toCard: Card) -> None:
"""
Transfer review history from one card to another.
Delete the source afterwards if configured to do so.
Search and reselect target card afterwards.
"""
self.transferData(fromCard, toCard, "Transferred", lambda: self.transferInDb(fromCard, toCard))
def copyReviewHistory(self, fromCard: Card, toCard: Card) -> None:
"""
Copy review history from one card to another.
Search and reselect target card afterwards.
"""
self.transferData(fromCard, toCard, "Copied", lambda: self.copyInDb(fromCard, toCard))
def copyReviewHistoryModified(self, fromCard: Card, toCard: Card) -> None:
"""
Copy review history from one card to another.
Search and reselect target card afterwards.
Manhhao: runs the modified function instead
"""
self.transferDataModified(fromCard, toCard, "Copied", lambda: self.copyInDb(fromCard, toCard))
def transferDataModified(self, fromCard: Card, toCard: Card, keyword: str,
withTransaction: Callable[[], None]) -> None:
"""
Transfer review data from one card to another and call provided callback afterwards.
Manhhao: This is modified to be ran with my code
"""
# fromF1 = fromCard.note().fields[0]
# toF1 = toCard.note().fields[0]
# mw.progress.start()
self.copyCardStatsModified(fromCard, toCard)
self.prepareTargetCard(toCard)
mw.col.db.transact(withTransaction)
# mw.progress.finish()
# self.browser.search()
# self.browser.table.select_single_card(toCard.id)
fromCard.load()
toCard.load()
# tooltip("%s review history from '%s' to '%s'" % (keyword, truncateString(fromF1), truncateString(toF1)) )
def transferData(self, fromCard: Card, toCard: Card, keyword: str, withTransaction: Callable[[], None]) -> None:
"""
Transfer review data from one card to another and call provided callback afterwards.
"""
fromF1 = fromCard.note().fields[0]
toF1 = toCard.note().fields[0]
mw.progress.start()
self.copyCardStats(fromCard, toCard)
self.prepareTargetCard(toCard)
mw.col.db.transact(withTransaction)
mw.progress.finish()
self.browser.search()
self.browser.table.select_single_card(toCard.id)
fromCard.load()
toCard.load()
tooltip("%s review history from '%s' to '%s'" % (keyword, truncateString(fromF1), truncateString(toF1)))
def copyCardStats(self, fromCard: Card, toCard: Card) -> None:
"""
Copy card stats from one card to another.
"""
toCard.type = fromCard.type
toCard.queue = fromCard.queue
toCard.ivl = fromCard.ivl
toCard.factor = fromCard.factor
toCard.lapses = fromCard.lapses
toCard.left = fromCard.left
toCard.due = fromCard.due
toCard.odue = fromCard.odue
toCard.desired_retention = fromCard.desired_retention
toCard.memory_state = fromCard.memory_state
toCard.mod = int_time()
def copyCardStatsModified(self, fromCard: Card, toCard: Card) -> None:
"""
Copy card stats from one card to another.
"""
# don't modify card if core card hasn't been reviewed yet, it's probably enough to check one of these but eh
if not (fromCard.reps == 0 and fromCard.ivl == 0):
toCard.mod = int_time()
toCard.type = fromCard.type
toCard.queue = fromCard.queue
toCard.due = fromCard.due
toCard.ivl = fromCard.ivl
toCard.factor = fromCard.factor
toCard.reps = fromCard.reps
toCard.lapses = fromCard.lapses
toCard.left = fromCard.left
# save correct sorting order in case you reset the card
toCard.original_position = toCard.due
toCard.due = fromCard.due
toCard.odue = fromCard.odue
toCard.desired_retention = fromCard.desired_retention
toCard.memory_state = fromCard.memory_state
def transferInDb(self, fromCard: Card, toCard: Card) -> None:
"""
Move revlog from one card to another.
Delete the source afterwards if configured to do so.
Perform in transaction!
"""
mw.col.db.all("UPDATE revlog SET cid = ? WHERE cid = ?", toCard.id, fromCard.id)
if (self.config.deleteSourceCard):
mw.col.remove_notes_by_card(card_ids=[fromCard.id])
else:
mw.col.db.all(
"UPDATE cards SET mod = %s, type = 0, queue = 0, ivl = 0, factor = 0, reps = 0, lapses = 0, left = 0 WHERE id = ?" % (
int_time()), fromCard.id)
fromCard.load()
fromCard.desired_retention = None
fromCard.memory_state = None
mw.col.update_card(fromCard, skip_undo_entry=True)
def copyInDb(self, fromCard: Card, toCard: Card) -> None:
"""
Copy revlog from one card to another.
Perform in transaction!
"""
COLUMNS = ['id', 'usn', 'ease', 'ivl', 'lastIvl', 'factor', 'time', 'type']
ROWS = ', '.join(COLUMNS)
QUERY = 'select %s from revlog where cid = ?' % ROWS
for row in mw.col.db.all(QUERY, fromCard.id):
sql = 'insert into revlog (%s, cid) values (%s, %s, %s)' % (
ROWS, self.getNextId(row[0]), ', '.join(map(str, row[1:])), toCard.id)
mw.col.db.all(sql)
def getNextId(self, rootId: int) -> int:
"Return the nearest non-conflicting timestamp id for revlog."
timestamp = rootId
while mw.col.db.scalar(f"select id from revlog where id = ?", timestamp):
timestamp += 1
return timestamp
def prepareTargetCard(self, toCard: Card) -> None:
"""
Prepare actual data transfer:
* Persist card changes in database
Perform in transaction!
"""
# As it is not easily doable to transfer the review data by anki itself
# We do not allow undoing this action.
# Deleting the card though will be undoable by default
mw.col.update_card(toCard, skip_undo_entry=True)
if not self.config.mergeHistories:
mw.col.db.all("DELETE FROM revlog WHERE cid = ?", toCard.id)
review_history = ReviewHistory()
review_history.initEvents()
def testFunction() -> None:
# deck from where ids should be copied over
source_deck = mw.col.decks.by_name(review_history.config.sourceDeck)
source_cards = mw.col.decks.cids(source_deck["id"])
# where to copy ids to
target_deck = mw.col.decks.by_name(review_history.config.targetDeck)
target_cards = mw.col.decks.cids(target_deck["id"])
c = transfer_history(source_cards, target_cards)
showInfo("updated: %d cards" % c)
def transfer_history(source, target):
counter = 0
for x in source:
card_to_update = mw.col.get_card(x)
note_to_update = card_to_update.note()
word = str(note_to_update["Word"])
word_reading = str(note_to_update["Word Reading"])
for y in target:
c = mw.col.get_card(y)
n = c.note()
c_word = str(n["Word"])
c_word_reading = str(n["Word Reading"])
if word == c_word and word_reading == c_word_reading:
review_history.copyReviewHistoryModified(c, card_to_update)
print(str(card_to_update.id) + " " + str(c.id))
print("updated " + word)
counter = counter + 1
return counter
action = QAction("Review History to Kaishi 1.5k", mw)
qconnect(action.triggered, testFunction)
mw.form.menuTools.addAction(action)