Track your Anki study sessions directly into Yōten.
1import time
2from dataclasses import dataclass
3from datetime import datetime
4from typing import Optional
5
6
7@dataclass
8class StudySession:
9 deck_name: str
10 cards_studied: int
11 duration_seconds: int
12 session_end: datetime
13
14 @property
15 def description(self) -> str:
16 return f"Anki: {self.deck_name} - {self.cards_studied} cards"
17
18 @property
19 def formatted_duration(self) -> str:
20 mins, secs = divmod(self.duration_seconds, 60)
21 if mins > 0:
22 return f"{mins}m {secs}s"
23 return f"{secs}s"
24
25 @property
26 def duration_parts(self) -> tuple[int, int, int]:
27 hours = self.duration_seconds // 3600
28 minutes = (self.duration_seconds % 3600) // 60
29 seconds = self.duration_seconds % 60
30 return (hours, minutes, seconds)
31
32
33class SessionTracker:
34 def __init__(self, min_cards: int = 5, min_duration: int = 30):
35 self.min_cards = min_cards
36 self.min_duration = min_duration
37 self.reset()
38
39 def reset(self):
40 self.session_active = False
41 self.session_start: Optional[float] = None
42 self.cards_count = 0
43 self.deck_name: Optional[str] = None
44 self.deck_id: Optional[int] = None
45
46 def _start_new_session(self, deck_id: int, deck_name: str) -> None:
47 self.session_active = True
48 self.session_start = time.time()
49 self.cards_count = 1
50 self.deck_name = deck_name
51 self.deck_id = deck_id
52
53 def on_card_shown(self, card):
54 from aqt import mw
55
56 current_deck = mw.col.decks.current()
57 current_deck_id = current_deck['id']
58 current_deck_name = current_deck['name']
59
60 if not self.session_active:
61 # Start new session
62 self._start_new_session(current_deck_id, current_deck_name)
63 elif self.deck_id != current_deck_id:
64 # Deck changed - end current session and start new one
65 if self.cards_count > 0:
66 self.end_session()
67 self._start_new_session(current_deck_id, current_deck_name)
68 else:
69 # Same deck, increment counter
70 self.cards_count += 1
71
72 def on_state_change(self, new_state: str, old_state: str):
73 if old_state == "review" and self.session_active:
74 if self.cards_count > 0:
75 self.end_session()
76
77 def end_session(self):
78 if not self.session_active or self.session_start is None:
79 return
80
81 duration = int(time.time() - self.session_start)
82
83 # Too few cards - don't post
84 if self.cards_count < self.min_cards:
85 self.reset()
86 return
87
88 # Too short - don't post
89 if duration < self.min_duration:
90 self.reset()
91 return
92
93 session = StudySession(
94 deck_name=self.deck_name,
95 cards_studied=self.cards_count,
96 duration_seconds=duration,
97 session_end=datetime.utcnow()
98 )
99
100 self.reset()
101
102 # Imported here to avoid circular import
103 from aqt import mw
104
105 # Schedule on main thread to ensure UI updates properly
106 mw.taskman.run_on_main(
107 lambda: self._show_dialog(session)
108 )
109
110 def _show_dialog(self, session: StudySession):
111 # Imported here to avoid circular import
112 from . import show_post_dialog
113 show_post_dialog(session)
114
115 def update_thresholds(self, min_cards: Optional[int] = None,
116 min_duration: Optional[int] = None):
117 if min_cards is not None:
118 self.min_cards = min_cards
119 if min_duration is not None:
120 self.min_duration = min_duration