GameSentenceMiner 2.15.9__py3-none-any.whl → 2.15.11__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.
- GameSentenceMiner/anki.py +31 -0
- GameSentenceMiner/ocr/gsm_ocr_config.py +1 -1
- GameSentenceMiner/ocr/owocr_helper.py +22 -13
- GameSentenceMiner/owocr/owocr/run.py +2 -2
- GameSentenceMiner/util/configuration.py +3 -0
- GameSentenceMiner/util/text_log.py +2 -2
- GameSentenceMiner/web/database_api.py +783 -0
- GameSentenceMiner/web/events.py +178 -0
- GameSentenceMiner/web/stats.py +582 -0
- GameSentenceMiner/web/templates/anki_stats.html +205 -0
- GameSentenceMiner/web/templates/database.html +277 -0
- GameSentenceMiner/web/templates/search.html +103 -0
- GameSentenceMiner/web/templates/stats.html +334 -0
- GameSentenceMiner/web/templates/utility.html +2 -2
- GameSentenceMiner/web/texthooking_page.py +108 -316
- GameSentenceMiner/web/websockets.py +120 -0
- {gamesentenceminer-2.15.9.dist-info → gamesentenceminer-2.15.11.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.15.9.dist-info → gamesentenceminer-2.15.11.dist-info}/RECORD +22 -16
- GameSentenceMiner/web/templates/__init__.py +0 -0
- GameSentenceMiner/web/templates/text_replacements.html +0 -238
- {gamesentenceminer-2.15.9.dist-info → gamesentenceminer-2.15.11.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.15.9.dist-info → gamesentenceminer-2.15.11.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.15.9.dist-info → gamesentenceminer-2.15.11.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.15.9.dist-info → gamesentenceminer-2.15.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,582 @@
|
|
1
|
+
import datetime
|
2
|
+
from collections import defaultdict
|
3
|
+
|
4
|
+
from GameSentenceMiner.util.db import GameLinesTable
|
5
|
+
from GameSentenceMiner.util.configuration import logger, get_config
|
6
|
+
|
7
|
+
|
8
|
+
def is_kanji(char):
|
9
|
+
"""Check if a character is a kanji (CJK Unified Ideographs)."""
|
10
|
+
# Validate input is a single character
|
11
|
+
if not isinstance(char, str) or len(char) != 1:
|
12
|
+
logger.warning(f"is_kanji() received invalid input: {repr(char)} (type: {type(char)}, length: {len(char) if isinstance(char, str) else 'N/A'})")
|
13
|
+
return False
|
14
|
+
|
15
|
+
try:
|
16
|
+
code_point = ord(char)
|
17
|
+
# CJK Unified Ideographs (most common kanji range)
|
18
|
+
# U+4E00-U+9FAF covers the main kanji characters
|
19
|
+
return 0x4E00 <= code_point <= 0x9FAF
|
20
|
+
except (TypeError, ValueError) as e:
|
21
|
+
logger.warning(f"is_kanji() failed to process character {repr(char)}: {e}")
|
22
|
+
return False
|
23
|
+
|
24
|
+
def interpolate_color(color1, color2, factor):
|
25
|
+
"""Interpolate between two hex colors."""
|
26
|
+
# Convert hex to RGB
|
27
|
+
def hex_to_rgb(hex_color):
|
28
|
+
hex_color = hex_color.lstrip('#')
|
29
|
+
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
30
|
+
|
31
|
+
# Convert RGB to hex
|
32
|
+
def rgb_to_hex(rgb):
|
33
|
+
return f"#{int(rgb[0]):02x}{int(rgb[1]):02x}{int(rgb[2]):02x}"
|
34
|
+
|
35
|
+
rgb1 = hex_to_rgb(color1)
|
36
|
+
rgb2 = hex_to_rgb(color2)
|
37
|
+
|
38
|
+
# Interpolate each channel
|
39
|
+
rgb_result = tuple(
|
40
|
+
rgb1[i] + factor * (rgb2[i] - rgb1[i])
|
41
|
+
for i in range(3)
|
42
|
+
)
|
43
|
+
|
44
|
+
return rgb_to_hex(rgb_result)
|
45
|
+
|
46
|
+
def get_gradient_color(frequency, max_frequency):
|
47
|
+
"""Get color from gradient based on frequency."""
|
48
|
+
if max_frequency == 0:
|
49
|
+
return "#ebedf0" # Default color for no encounters
|
50
|
+
|
51
|
+
# kanji with 500+ encounters should always get cyan color cause i think u should know them
|
52
|
+
if frequency > 500:
|
53
|
+
return "#2ee6e0"
|
54
|
+
|
55
|
+
# Normalize frequency to 0-1 range with square root transformation
|
56
|
+
# This creates a smoother, more visually pleasing gradient by spreading
|
57
|
+
# out the lower frequencies (since kanji frequency follows Zipf's law)
|
58
|
+
ratio = (frequency / max_frequency) ** 0.5
|
59
|
+
|
60
|
+
# Define gradient colors: least seen → most seen
|
61
|
+
# #e6342e (red) → #e6dc2e (yellow) → #3be62f (green) → #2ee6e0 (cyan)
|
62
|
+
colors = ["#e6342e", "#e6dc2e", "#3be62f", "#2ee6e0"]
|
63
|
+
|
64
|
+
if ratio == 0:
|
65
|
+
return "#ebedf0" # No encounters
|
66
|
+
|
67
|
+
# Scale ratio to fit the 3 gradient segments
|
68
|
+
scaled_ratio = ratio * (len(colors) - 1)
|
69
|
+
segment = int(scaled_ratio)
|
70
|
+
local_ratio = scaled_ratio - segment
|
71
|
+
|
72
|
+
# Clamp segment to valid range
|
73
|
+
if segment >= len(colors) - 1:
|
74
|
+
return colors[-1]
|
75
|
+
|
76
|
+
# Interpolate between adjacent colors
|
77
|
+
return interpolate_color(colors[segment], colors[segment + 1], local_ratio)
|
78
|
+
|
79
|
+
def calculate_kanji_frequency(all_lines):
|
80
|
+
"""Calculate frequency of kanji characters across all lines with gradient coloring."""
|
81
|
+
kanji_count = defaultdict(int)
|
82
|
+
|
83
|
+
for line in all_lines:
|
84
|
+
if line.line_text:
|
85
|
+
# Ensure line_text is a string and handle any encoding issues
|
86
|
+
try:
|
87
|
+
line_text = str(line.line_text) if line.line_text else ""
|
88
|
+
for char in line_text:
|
89
|
+
if is_kanji(char):
|
90
|
+
kanji_count[char] += 1
|
91
|
+
except Exception as e:
|
92
|
+
logger.warning(f"Error processing line text for kanji frequency: {repr(line.line_text)}, error: {e}")
|
93
|
+
continue
|
94
|
+
|
95
|
+
if not kanji_count:
|
96
|
+
return {
|
97
|
+
"kanji_data": [],
|
98
|
+
"unique_count": 0
|
99
|
+
}
|
100
|
+
|
101
|
+
# Find max frequency for gradient calculation
|
102
|
+
max_frequency = max(kanji_count.values())
|
103
|
+
|
104
|
+
# Sort kanji by frequency (most frequent first)
|
105
|
+
sorted_kanji = sorted(kanji_count.items(), key=lambda x: x[1], reverse=True)
|
106
|
+
|
107
|
+
# Add gradient colors to each kanji
|
108
|
+
kanji_data = []
|
109
|
+
for kanji, count in sorted_kanji:
|
110
|
+
color = get_gradient_color(count, max_frequency)
|
111
|
+
kanji_data.append({
|
112
|
+
"kanji": kanji,
|
113
|
+
"frequency": count,
|
114
|
+
"color": color
|
115
|
+
})
|
116
|
+
|
117
|
+
return {
|
118
|
+
"kanji_data": kanji_data,
|
119
|
+
"unique_count": len(sorted_kanji),
|
120
|
+
"max_frequency": max_frequency
|
121
|
+
}
|
122
|
+
|
123
|
+
def calculate_heatmap_data(all_lines, filter_year=None):
|
124
|
+
"""Calculate heatmap data for reading activity."""
|
125
|
+
heatmap_data = defaultdict(lambda: defaultdict(int))
|
126
|
+
|
127
|
+
for line in all_lines:
|
128
|
+
date_obj = datetime.date.fromtimestamp(float(line.timestamp))
|
129
|
+
year = str(date_obj.year)
|
130
|
+
|
131
|
+
# Filter by year if specified
|
132
|
+
if filter_year and year != filter_year:
|
133
|
+
continue
|
134
|
+
|
135
|
+
date_str = date_obj.strftime('%Y-%m-%d')
|
136
|
+
char_count = len(line.line_text) if line.line_text else 0
|
137
|
+
heatmap_data[year][date_str] += char_count
|
138
|
+
|
139
|
+
return dict(heatmap_data)
|
140
|
+
|
141
|
+
|
142
|
+
def calculate_total_chars_per_game(all_lines):
|
143
|
+
"""Calculate total characters read per game."""
|
144
|
+
game_data = defaultdict(lambda: {'total_chars': 0, 'first_time': None})
|
145
|
+
|
146
|
+
for line in all_lines:
|
147
|
+
game = line.game_name or "Unknown Game"
|
148
|
+
timestamp = float(line.timestamp)
|
149
|
+
char_count = len(line.line_text) if line.line_text else 0
|
150
|
+
|
151
|
+
game_data[game]['total_chars'] += char_count
|
152
|
+
|
153
|
+
if game_data[game]['first_time'] is None:
|
154
|
+
game_data[game]['first_time'] = timestamp
|
155
|
+
|
156
|
+
# Sort by first appearance time and filter out games with no characters
|
157
|
+
char_data = []
|
158
|
+
for game, data in game_data.items():
|
159
|
+
if data['total_chars'] > 0:
|
160
|
+
char_data.append((game, data['total_chars'], data['first_time']))
|
161
|
+
|
162
|
+
# Sort by first appearance time
|
163
|
+
char_data.sort(key=lambda x: x[2])
|
164
|
+
|
165
|
+
return {
|
166
|
+
"labels": [item[0] for item in char_data],
|
167
|
+
"totals": [item[1] for item in char_data]
|
168
|
+
}
|
169
|
+
|
170
|
+
def calculate_reading_time_per_game(all_lines):
|
171
|
+
"""Calculate total reading time per game in hours using AFK timer logic."""
|
172
|
+
game_data = defaultdict(lambda: {'timestamps': [], 'first_time': None})
|
173
|
+
|
174
|
+
for line in all_lines:
|
175
|
+
game = line.game_name or "Unknown Game"
|
176
|
+
timestamp = float(line.timestamp)
|
177
|
+
|
178
|
+
game_data[game]['timestamps'].append(timestamp)
|
179
|
+
if game_data[game]['first_time'] is None:
|
180
|
+
game_data[game]['first_time'] = timestamp
|
181
|
+
|
182
|
+
# Calculate actual reading time for each game
|
183
|
+
time_data = []
|
184
|
+
for game, data in game_data.items():
|
185
|
+
if len(data['timestamps']) >= 2:
|
186
|
+
# Use actual reading time calculation
|
187
|
+
reading_time_seconds = calculate_actual_reading_time(data['timestamps'])
|
188
|
+
hours = reading_time_seconds / 3600 # Convert to hours
|
189
|
+
if hours > 0:
|
190
|
+
time_data.append((game, hours, data['first_time']))
|
191
|
+
|
192
|
+
# Sort by first appearance time
|
193
|
+
time_data.sort(key=lambda x: x[2])
|
194
|
+
|
195
|
+
return {
|
196
|
+
"labels": [item[0] for item in time_data],
|
197
|
+
"totals": [round(item[1], 2) for item in time_data] # Round to 2 decimals for hours
|
198
|
+
}
|
199
|
+
|
200
|
+
def calculate_reading_speed_per_game(all_lines):
|
201
|
+
"""Calculate average reading speed per game (chars/hour) using AFK timer logic."""
|
202
|
+
game_data = defaultdict(lambda: {'chars': 0, 'timestamps': [], 'first_time': None})
|
203
|
+
|
204
|
+
for line in all_lines:
|
205
|
+
game = line.game_name or "Unknown Game"
|
206
|
+
timestamp = float(line.timestamp)
|
207
|
+
char_count = len(line.line_text) if line.line_text else 0
|
208
|
+
|
209
|
+
game_data[game]['chars'] += char_count
|
210
|
+
game_data[game]['timestamps'].append(timestamp)
|
211
|
+
|
212
|
+
if game_data[game]['first_time'] is None:
|
213
|
+
game_data[game]['first_time'] = timestamp
|
214
|
+
|
215
|
+
# Calculate speeds using actual reading time
|
216
|
+
speed_data = []
|
217
|
+
for game, data in game_data.items():
|
218
|
+
if len(data['timestamps']) >= 2 and data['chars'] > 0:
|
219
|
+
# Use actual reading time calculation
|
220
|
+
reading_time_seconds = calculate_actual_reading_time(data['timestamps'])
|
221
|
+
hours = reading_time_seconds / 3600 # Convert to hours
|
222
|
+
if hours > 0:
|
223
|
+
speed = data['chars'] / hours
|
224
|
+
speed_data.append((game, speed, data['first_time']))
|
225
|
+
|
226
|
+
# Sort by first appearance time
|
227
|
+
speed_data.sort(key=lambda x: x[2])
|
228
|
+
|
229
|
+
return {
|
230
|
+
"labels": [item[0] for item in speed_data],
|
231
|
+
"totals": [round(item[1], 0) for item in speed_data] # Round to whole numbers for chars/hour
|
232
|
+
}
|
233
|
+
|
234
|
+
def generate_game_colors(game_count):
|
235
|
+
"""Generate visually distinct colors for games using HSL color space."""
|
236
|
+
colors = []
|
237
|
+
|
238
|
+
# Predefined set of good colors for the first few games
|
239
|
+
predefined_colors = [
|
240
|
+
'#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6',
|
241
|
+
'#1abc9c', '#e67e22', '#34495e', '#16a085', '#27ae60',
|
242
|
+
'#2980b9', '#8e44ad', '#d35400', '#c0392b', '#7f8c8d'
|
243
|
+
]
|
244
|
+
|
245
|
+
# Use predefined colors first
|
246
|
+
for i in range(min(game_count, len(predefined_colors))):
|
247
|
+
colors.append(predefined_colors[i])
|
248
|
+
|
249
|
+
# Generate additional colors using HSL if needed
|
250
|
+
if game_count > len(predefined_colors):
|
251
|
+
remaining = game_count - len(predefined_colors)
|
252
|
+
for i in range(remaining):
|
253
|
+
# Distribute hue evenly across the color wheel
|
254
|
+
hue = (i * 360 / remaining) % 360
|
255
|
+
# Use varied saturation and lightness for visual distinction
|
256
|
+
saturation = 65 + (i % 3) * 10 # 65%, 75%, 85%
|
257
|
+
lightness = 45 + (i % 2) * 10 # 45%, 55%
|
258
|
+
|
259
|
+
# Convert HSL to hex
|
260
|
+
colors.append(f'hsl({hue:.0f}, {saturation}%, {lightness}%)')
|
261
|
+
|
262
|
+
return colors
|
263
|
+
|
264
|
+
def format_large_number(num):
|
265
|
+
"""Format large numbers with appropriate units (K for thousands, M for millions)."""
|
266
|
+
if num >= 1000000:
|
267
|
+
return f"{num / 1000000:.1f}M"
|
268
|
+
elif num >= 1000:
|
269
|
+
return f"{num / 1000:.1f}K"
|
270
|
+
else:
|
271
|
+
return str(int(num))
|
272
|
+
|
273
|
+
def calculate_actual_reading_time(timestamps, afk_timer_seconds=None):
|
274
|
+
"""
|
275
|
+
Calculate actual reading time using AFK timer logic.
|
276
|
+
|
277
|
+
Args:
|
278
|
+
timestamps: List of timestamps (as floats)
|
279
|
+
afk_timer_seconds: Maximum time between entries to count as active reading.
|
280
|
+
If None, uses config value. Defaults to 120 seconds (2 minutes).
|
281
|
+
|
282
|
+
Returns:
|
283
|
+
float: Actual reading time in seconds
|
284
|
+
"""
|
285
|
+
if not timestamps or len(timestamps) < 2:
|
286
|
+
return 0.0
|
287
|
+
|
288
|
+
if afk_timer_seconds is None:
|
289
|
+
afk_timer_seconds = get_config().advanced.afk_timer_seconds
|
290
|
+
|
291
|
+
# Sort timestamps to ensure chronological order
|
292
|
+
sorted_timestamps = sorted(timestamps)
|
293
|
+
total_reading_time = 0.0
|
294
|
+
|
295
|
+
# Calculate time between consecutive entries
|
296
|
+
for i in range(1, len(sorted_timestamps)):
|
297
|
+
time_gap = sorted_timestamps[i] - sorted_timestamps[i-1]
|
298
|
+
|
299
|
+
# Cap the gap at AFK timer limit
|
300
|
+
if time_gap > afk_timer_seconds:
|
301
|
+
total_reading_time += afk_timer_seconds
|
302
|
+
else:
|
303
|
+
total_reading_time += time_gap
|
304
|
+
|
305
|
+
return total_reading_time
|
306
|
+
|
307
|
+
def calculate_daily_reading_time(lines):
|
308
|
+
"""
|
309
|
+
Calculate actual reading time per day using AFK timer logic.
|
310
|
+
|
311
|
+
Args:
|
312
|
+
lines: List of game lines
|
313
|
+
|
314
|
+
Returns:
|
315
|
+
dict: Dictionary mapping date strings to reading time in hours
|
316
|
+
"""
|
317
|
+
daily_timestamps = defaultdict(list)
|
318
|
+
|
319
|
+
# Group timestamps by day
|
320
|
+
for line in lines:
|
321
|
+
date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
322
|
+
daily_timestamps[date_str].append(float(line.timestamp))
|
323
|
+
|
324
|
+
# Calculate reading time for each day
|
325
|
+
daily_reading_time = {}
|
326
|
+
for date_str, timestamps in daily_timestamps.items():
|
327
|
+
if len(timestamps) >= 2:
|
328
|
+
reading_time_seconds = calculate_actual_reading_time(timestamps)
|
329
|
+
daily_reading_time[date_str] = reading_time_seconds / 3600 # Convert to hours
|
330
|
+
else:
|
331
|
+
daily_reading_time[date_str] = 0.0
|
332
|
+
|
333
|
+
return daily_reading_time
|
334
|
+
|
335
|
+
def calculate_time_based_streak(lines, streak_requirement_hours=None):
|
336
|
+
"""
|
337
|
+
Calculate reading streak based on time requirements rather than daily activity.
|
338
|
+
|
339
|
+
Args:
|
340
|
+
lines: List of game lines
|
341
|
+
streak_requirement_hours: Minimum hours of reading per day to maintain streak.
|
342
|
+
If None, uses config value. Defaults to 1.0.
|
343
|
+
|
344
|
+
Returns:
|
345
|
+
int: Current streak in days
|
346
|
+
"""
|
347
|
+
if streak_requirement_hours is None:
|
348
|
+
streak_requirement_hours = getattr(get_config().advanced, 'streak_requirement_hours', 1.0)
|
349
|
+
|
350
|
+
# Add debug logging
|
351
|
+
logger.debug(f"Calculating streak with requirement: {streak_requirement_hours} hours")
|
352
|
+
logger.debug(f"Processing {len(lines)} lines for streak calculation")
|
353
|
+
|
354
|
+
# Calculate daily reading time
|
355
|
+
daily_reading_time = calculate_daily_reading_time(lines)
|
356
|
+
|
357
|
+
if not daily_reading_time:
|
358
|
+
logger.debug("No daily reading time data available")
|
359
|
+
return 0
|
360
|
+
|
361
|
+
logger.debug(f"Daily reading time data: {dict(list(daily_reading_time.items())[:5])}") # Show first 5 days
|
362
|
+
|
363
|
+
# Check streak from today backwards
|
364
|
+
today = datetime.date.today()
|
365
|
+
current_streak = 0
|
366
|
+
|
367
|
+
check_date = today
|
368
|
+
consecutive_days_checked = 0
|
369
|
+
while consecutive_days_checked < 365: # Check max 365 days back
|
370
|
+
date_str = check_date.strftime('%Y-%m-%d')
|
371
|
+
reading_hours = daily_reading_time.get(date_str, 0.0)
|
372
|
+
|
373
|
+
logger.debug(f"Checking {date_str}: {reading_hours:.4f} hours vs requirement {streak_requirement_hours}")
|
374
|
+
|
375
|
+
if reading_hours >= streak_requirement_hours:
|
376
|
+
current_streak += 1
|
377
|
+
logger.debug(f"Day {date_str} qualifies for streak. Current streak: {current_streak}")
|
378
|
+
else:
|
379
|
+
logger.debug(f"Day {date_str} breaks streak. Reading hours {reading_hours:.4f} < requirement {streak_requirement_hours}")
|
380
|
+
break
|
381
|
+
|
382
|
+
check_date -= datetime.timedelta(days=1)
|
383
|
+
consecutive_days_checked += 1
|
384
|
+
|
385
|
+
logger.debug(f"Final calculated streak: {current_streak} days")
|
386
|
+
return current_streak
|
387
|
+
|
388
|
+
|
389
|
+
def format_time_human_readable(hours):
|
390
|
+
"""Format time in human-readable format (hours and minutes)."""
|
391
|
+
if hours < 1:
|
392
|
+
minutes = int(hours * 60)
|
393
|
+
return f"{minutes}m"
|
394
|
+
elif hours < 24:
|
395
|
+
whole_hours = int(hours)
|
396
|
+
minutes = int((hours - whole_hours) * 60)
|
397
|
+
if minutes > 0:
|
398
|
+
return f"{whole_hours}h {minutes}m"
|
399
|
+
else:
|
400
|
+
return f"{whole_hours}h"
|
401
|
+
else:
|
402
|
+
days = int(hours / 24)
|
403
|
+
remaining_hours = int(hours % 24)
|
404
|
+
if remaining_hours > 0:
|
405
|
+
return f"{days}d {remaining_hours}h"
|
406
|
+
else:
|
407
|
+
return f"{days}d"
|
408
|
+
|
409
|
+
def calculate_current_game_stats(all_lines):
|
410
|
+
"""Calculate statistics for the currently active game (most recent entry)."""
|
411
|
+
if not all_lines:
|
412
|
+
return None
|
413
|
+
|
414
|
+
# Sort lines by timestamp to find the most recent
|
415
|
+
sorted_lines = sorted(all_lines, key=lambda line: float(line.timestamp))
|
416
|
+
|
417
|
+
# Get the current game (game with most recent entry)
|
418
|
+
current_game_name = sorted_lines[-1].game_name or "Unknown Game"
|
419
|
+
|
420
|
+
# Filter lines for current game
|
421
|
+
current_game_lines = [line for line in all_lines if (line.game_name or "Unknown Game") == current_game_name]
|
422
|
+
|
423
|
+
if not current_game_lines:
|
424
|
+
return None
|
425
|
+
|
426
|
+
# Calculate basic statistics
|
427
|
+
total_characters = sum(len(line.line_text) if line.line_text else 0 for line in current_game_lines)
|
428
|
+
total_sentences = len(current_game_lines)
|
429
|
+
|
430
|
+
# Calculate actual reading time using AFK timer
|
431
|
+
timestamps = [float(line.timestamp) for line in current_game_lines]
|
432
|
+
min_timestamp = min(timestamps)
|
433
|
+
max_timestamp = max(timestamps)
|
434
|
+
total_time_seconds = calculate_actual_reading_time(timestamps)
|
435
|
+
total_time_hours = total_time_seconds / 3600
|
436
|
+
|
437
|
+
# Calculate reading speed (with edge case handling)
|
438
|
+
reading_speed = int(total_characters / total_time_hours) if total_time_hours > 0 else 0
|
439
|
+
|
440
|
+
# Calculate sessions (gaps of more than session_gap_seconds = new session)
|
441
|
+
sorted_timestamps = sorted(timestamps)
|
442
|
+
sessions = 1
|
443
|
+
for i in range(1, len(sorted_timestamps)):
|
444
|
+
time_gap = sorted_timestamps[i] - sorted_timestamps[i-1]
|
445
|
+
if time_gap > get_config().advanced.session_gap_seconds:
|
446
|
+
sessions += 1
|
447
|
+
|
448
|
+
# Calculate daily activity for progress trend
|
449
|
+
daily_activity = defaultdict(int)
|
450
|
+
for line in current_game_lines:
|
451
|
+
date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
452
|
+
daily_activity[date_str] += len(line.line_text) if line.line_text else 0
|
453
|
+
|
454
|
+
# Calculate monthly progress (last 30 days)
|
455
|
+
today = datetime.date.today()
|
456
|
+
monthly_chars = 0
|
457
|
+
for i in range(30):
|
458
|
+
date = today - datetime.timedelta(days=i)
|
459
|
+
date_str = date.strftime('%Y-%m-%d')
|
460
|
+
monthly_chars += daily_activity.get(date_str, 0)
|
461
|
+
|
462
|
+
# Calculate reading streak using time-based requirements
|
463
|
+
current_streak = calculate_time_based_streak(current_game_lines)
|
464
|
+
|
465
|
+
return {
|
466
|
+
'game_name': current_game_name,
|
467
|
+
'total_characters': total_characters,
|
468
|
+
'total_characters_formatted': format_large_number(total_characters),
|
469
|
+
'total_sentences': total_sentences,
|
470
|
+
'total_time_hours': total_time_hours,
|
471
|
+
'total_time_formatted': format_time_human_readable(total_time_hours),
|
472
|
+
'reading_speed': reading_speed,
|
473
|
+
'reading_speed_formatted': format_large_number(reading_speed),
|
474
|
+
'sessions': sessions,
|
475
|
+
'monthly_characters': monthly_chars,
|
476
|
+
'monthly_characters_formatted': format_large_number(monthly_chars),
|
477
|
+
'current_streak': current_streak,
|
478
|
+
'first_date': datetime.date.fromtimestamp(min_timestamp).strftime('%Y-%m-%d'),
|
479
|
+
'last_date': datetime.date.fromtimestamp(max_timestamp).strftime('%Y-%m-%d'),
|
480
|
+
'daily_activity': dict(daily_activity)
|
481
|
+
}
|
482
|
+
|
483
|
+
def calculate_average_daily_reading_time(all_lines):
|
484
|
+
"""
|
485
|
+
Calculate average reading time per day based only on days with reading activity.
|
486
|
+
|
487
|
+
Args:
|
488
|
+
all_lines: List of game lines
|
489
|
+
|
490
|
+
Returns:
|
491
|
+
float: Average reading time in hours per active day, 0 if no active days
|
492
|
+
"""
|
493
|
+
if not all_lines:
|
494
|
+
return 0.0
|
495
|
+
|
496
|
+
# Calculate daily reading time using existing function
|
497
|
+
daily_reading_time = calculate_daily_reading_time(all_lines)
|
498
|
+
|
499
|
+
if not daily_reading_time:
|
500
|
+
return 0.0
|
501
|
+
|
502
|
+
# Count only days with reading activity > 0
|
503
|
+
active_days = [day_hours for day_hours in daily_reading_time.values() if day_hours > 0]
|
504
|
+
|
505
|
+
if not active_days:
|
506
|
+
return 0.0
|
507
|
+
|
508
|
+
# Calculate average: total hours / number of active days
|
509
|
+
total_hours = sum(active_days)
|
510
|
+
average_hours = total_hours / len(active_days)
|
511
|
+
|
512
|
+
return average_hours
|
513
|
+
|
514
|
+
def calculate_all_games_stats(all_lines):
|
515
|
+
"""Calculate aggregate statistics for all games combined."""
|
516
|
+
if not all_lines:
|
517
|
+
return None
|
518
|
+
|
519
|
+
# Calculate basic statistics
|
520
|
+
total_characters = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
|
521
|
+
total_sentences = len(all_lines)
|
522
|
+
|
523
|
+
# Calculate actual reading time using AFK timer
|
524
|
+
timestamps = [float(line.timestamp) for line in all_lines]
|
525
|
+
min_timestamp = min(timestamps)
|
526
|
+
max_timestamp = max(timestamps)
|
527
|
+
total_time_seconds = calculate_actual_reading_time(timestamps)
|
528
|
+
total_time_hours = total_time_seconds / 3600
|
529
|
+
|
530
|
+
# Calculate reading speed (with edge case handling)
|
531
|
+
reading_speed = int(total_characters / total_time_hours) if total_time_hours > 0 else 0
|
532
|
+
|
533
|
+
# Calculate sessions across all games (gaps of more than 1 hour = new session)
|
534
|
+
sorted_timestamps = sorted(timestamps)
|
535
|
+
sessions = 1
|
536
|
+
for i in range(1, len(sorted_timestamps)):
|
537
|
+
time_gap = sorted_timestamps[i] - sorted_timestamps[i-1]
|
538
|
+
if time_gap > 3600: # 1 hour gap
|
539
|
+
sessions += 1
|
540
|
+
|
541
|
+
# Calculate daily activity for progress trend
|
542
|
+
daily_activity = defaultdict(int)
|
543
|
+
for line in all_lines:
|
544
|
+
date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
545
|
+
daily_activity[date_str] += len(line.line_text) if line.line_text else 0
|
546
|
+
|
547
|
+
# Calculate monthly progress (last 30 days)
|
548
|
+
today = datetime.date.today()
|
549
|
+
monthly_chars = 0
|
550
|
+
for i in range(30):
|
551
|
+
date = today - datetime.timedelta(days=i)
|
552
|
+
date_str = date.strftime('%Y-%m-%d')
|
553
|
+
monthly_chars += daily_activity.get(date_str, 0)
|
554
|
+
|
555
|
+
# Calculate reading streak using time-based requirements
|
556
|
+
current_streak = calculate_time_based_streak(all_lines)
|
557
|
+
|
558
|
+
# Calculate average daily reading time
|
559
|
+
avg_daily_time_hours = calculate_average_daily_reading_time(all_lines)
|
560
|
+
|
561
|
+
# Count unique games
|
562
|
+
unique_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
|
563
|
+
|
564
|
+
return {
|
565
|
+
'total_characters': total_characters,
|
566
|
+
'total_characters_formatted': format_large_number(total_characters),
|
567
|
+
'total_sentences': total_sentences,
|
568
|
+
'total_time_hours': total_time_hours,
|
569
|
+
'total_time_formatted': format_time_human_readable(total_time_hours),
|
570
|
+
'reading_speed': reading_speed,
|
571
|
+
'reading_speed_formatted': format_large_number(reading_speed),
|
572
|
+
'sessions': sessions,
|
573
|
+
'unique_games': unique_games,
|
574
|
+
'monthly_characters': monthly_chars,
|
575
|
+
'monthly_characters_formatted': format_large_number(monthly_chars),
|
576
|
+
'current_streak': current_streak,
|
577
|
+
'avg_daily_time_hours': avg_daily_time_hours,
|
578
|
+
'avg_daily_time_formatted': format_time_human_readable(avg_daily_time_hours),
|
579
|
+
'first_date': datetime.date.fromtimestamp(min_timestamp).strftime('%Y-%m-%d'),
|
580
|
+
'last_date': datetime.date.fromtimestamp(max_timestamp).strftime('%Y-%m-%d'),
|
581
|
+
'daily_activity': dict(daily_activity)
|
582
|
+
}
|