GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.0__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.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -14,59 +14,129 @@ from flask import request, jsonify
14
14
  import regex
15
15
 
16
16
  from GameSentenceMiner.util.db import GameLinesTable
17
- from GameSentenceMiner.util.configuration import get_stats_config, logger, get_config, save_current_config, save_stats_config
17
+ from GameSentenceMiner.util.stats_rollup_table import StatsRollupTable
18
+ from GameSentenceMiner.util.configuration import (
19
+ get_stats_config,
20
+ logger,
21
+ get_config,
22
+ save_current_config,
23
+ save_stats_config,
24
+ )
25
+ from GameSentenceMiner.util.cron.daily_rollup import run_daily_rollup
18
26
  from GameSentenceMiner.util.text_log import GameLine
19
27
  from GameSentenceMiner.web.stats import (
20
- calculate_kanji_frequency, calculate_heatmap_data, calculate_mining_heatmap_data,
21
- calculate_total_chars_per_game, calculate_reading_time_per_game, calculate_reading_speed_per_game,
22
- calculate_current_game_stats, calculate_all_games_stats, calculate_daily_reading_time,
23
- calculate_time_based_streak, calculate_actual_reading_time, calculate_hourly_activity,
24
- calculate_hourly_reading_speed, calculate_peak_daily_stats, calculate_peak_session_stats
28
+ calculate_kanji_frequency,
29
+ calculate_mining_heatmap_data,
30
+ calculate_total_chars_per_game,
31
+ calculate_reading_time_per_game,
32
+ calculate_reading_speed_per_game,
33
+ calculate_current_game_stats,
34
+ calculate_all_games_stats,
35
+ calculate_daily_reading_time,
36
+ calculate_time_based_streak,
37
+ calculate_actual_reading_time,
38
+ calculate_hourly_activity,
39
+ calculate_hourly_reading_speed,
40
+ calculate_peak_daily_stats,
41
+ calculate_peak_session_stats,
42
+ calculate_game_milestones,
43
+ build_game_display_name_mapping,
44
+ )
45
+ from GameSentenceMiner.web.rollup_stats import (
46
+ aggregate_rollup_data,
47
+ calculate_live_stats_for_today,
48
+ combine_rollup_and_live_stats,
49
+ build_heatmap_from_rollup,
50
+ build_daily_chart_data_from_rollup,
25
51
  )
26
52
 
53
+
27
54
  def register_database_api_routes(app):
28
55
  """Register all database API routes with the Flask app."""
29
-
30
- @app.route('/api/search-sentences')
56
+
57
+ @app.route("/api/search-sentences")
31
58
  def api_search_sentences():
32
59
  """
33
60
  API endpoint for searching sentences with filters and pagination.
34
61
  """
35
62
  try:
36
63
  # Get query parameters
37
- query = request.args.get('q', '').strip()
38
- game_filter = request.args.get('game', '')
39
- sort_by = request.args.get('sort', 'relevance')
40
- page = int(request.args.get('page', 1))
41
- page_size = int(request.args.get('page_size', 20))
42
- use_regex = request.args.get('use_regex', 'false').lower() == 'true'
43
-
64
+ query = request.args.get("q", "").strip()
65
+ game_filter = request.args.get("game", "")
66
+ from_date = request.args.get("from_date", "").strip()
67
+ to_date = request.args.get("to_date", "").strip()
68
+ sort_by = request.args.get("sort", "relevance")
69
+ page = int(request.args.get("page", 1))
70
+ page_size = int(request.args.get("page_size", 20))
71
+ use_regex = request.args.get("use_regex", "false").lower() == "true"
72
+
44
73
  # Validate parameters
45
74
  if not query:
46
- return jsonify({'error': 'Search query is required'}), 400
47
-
75
+ return jsonify({"error": "Search query is required"}), 400
76
+
48
77
  if page < 1:
49
78
  page = 1
50
79
  if page_size < 1 or page_size > 200:
51
80
  page_size = 20
81
+
82
+ # Parse and validate date range if provided
83
+ date_start_timestamp = None
84
+ date_end_timestamp = None
85
+
86
+ if from_date:
87
+ try:
88
+ # Parse from_date in YYYY-MM-DD format
89
+ from_date_obj = datetime.datetime.strptime(from_date, "%Y-%m-%d")
90
+ # Get start of day (00:00:00)
91
+ date_start_timestamp = from_date_obj.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
92
+ except ValueError:
93
+ return jsonify({"error": "Invalid from_date format. Use YYYY-MM-DD"}), 400
94
+
95
+ if to_date:
96
+ try:
97
+ # Parse to_date in YYYY-MM-DD format
98
+ to_date_obj = datetime.datetime.strptime(to_date, "%Y-%m-%d")
99
+ # Get end of day (23:59:59)
100
+ date_end_timestamp = to_date_obj.replace(hour=23, minute=59, second=59, microsecond=999999).timestamp()
101
+ except ValueError:
102
+ return jsonify({"error": "Invalid to_date format. Use YYYY-MM-DD"}), 400
52
103
 
53
104
  if use_regex:
54
105
  # Regex search: fetch all candidate rows, filter in Python
55
106
  try:
56
107
  # Ensure query is a string
57
108
  if not isinstance(query, str):
58
- return jsonify({'error': 'Invalid query parameter type'}), 400
59
-
109
+ return jsonify({"error": "Invalid query parameter type"}), 400
110
+
60
111
  all_lines = GameLinesTable.all()
61
112
  if game_filter:
62
- all_lines = [line for line in all_lines if line.game_name == game_filter]
113
+ all_lines = [
114
+ line for line in all_lines if line.game_name == game_filter
115
+ ]
63
116
 
117
+ # Apply date range filter if provided
118
+ if date_start_timestamp is not None or date_end_timestamp is not None:
119
+ filtered_lines = []
120
+ for line in all_lines:
121
+ if not line.timestamp:
122
+ continue
123
+ timestamp = float(line.timestamp)
124
+ # Check if timestamp is within range
125
+ if date_start_timestamp is not None and timestamp < date_start_timestamp:
126
+ continue
127
+ if date_end_timestamp is not None and timestamp > date_end_timestamp:
128
+ continue
129
+ filtered_lines.append(line)
130
+ all_lines = filtered_lines
131
+
64
132
  # Compile regex pattern with proper error handling
65
133
  try:
66
134
  pattern = re.compile(query, re.IGNORECASE)
67
135
  except re.error as regex_err:
68
- return jsonify({'error': f'Invalid regex pattern: {str(regex_err)}'}), 400
69
-
136
+ return jsonify(
137
+ {"error": f"Invalid regex pattern: {str(regex_err)}"}
138
+ ), 400
139
+
70
140
  # Filter lines using regex
71
141
  filtered_lines = []
72
142
  for line in all_lines:
@@ -76,17 +146,36 @@ def register_database_api_routes(app):
76
146
  filtered_lines.append(line)
77
147
  except Exception as search_err:
78
148
  # Log but continue with other lines
79
- logger.warning(f"Regex search error on line {line.id}: {search_err}")
149
+ logger.warning(
150
+ f"Regex search error on line {line.id}: {search_err}"
151
+ )
80
152
  continue
81
-
153
+
82
154
  # Sorting (default: timestamp DESC, or as specified)
83
- if sort_by == 'date_asc':
84
- filtered_lines.sort(key=lambda l: float(l.timestamp) if l.timestamp else 0)
85
- elif sort_by == 'game_name':
86
- filtered_lines.sort(key=lambda l: (l.game_name or '', -(float(l.timestamp) if l.timestamp else 0)))
155
+ if sort_by == "date_asc":
156
+ filtered_lines.sort(
157
+ key=lambda l: float(l.timestamp) if l.timestamp else 0
158
+ )
159
+ elif sort_by == "game_name":
160
+ filtered_lines.sort(
161
+ key=lambda l: (
162
+ l.game_name or "",
163
+ -(float(l.timestamp) if l.timestamp else 0),
164
+ )
165
+ )
166
+ elif sort_by == "length_desc":
167
+ filtered_lines.sort(
168
+ key=lambda l: -(len(l.line_text) if l.line_text else 0)
169
+ )
170
+ elif sort_by == "length_asc":
171
+ filtered_lines.sort(
172
+ key=lambda l: len(l.line_text) if l.line_text else 0
173
+ )
87
174
  else: # date_desc or relevance
88
- filtered_lines.sort(key=lambda l: -(float(l.timestamp) if l.timestamp else 0))
89
-
175
+ filtered_lines.sort(
176
+ key=lambda l: -(float(l.timestamp) if l.timestamp else 0)
177
+ )
178
+
90
179
  total_results = len(filtered_lines)
91
180
  # Pagination
92
181
  start = (page - 1) * page_size
@@ -94,92 +183,128 @@ def register_database_api_routes(app):
94
183
  paged_lines = filtered_lines[start:end]
95
184
  results = []
96
185
  for line in paged_lines:
97
- results.append({
98
- 'id': line.id,
99
- 'sentence': line.line_text or '',
100
- 'game_name': line.game_name or 'Unknown Game',
101
- 'timestamp': float(line.timestamp) if line.timestamp else 0,
102
- 'translation': line.translation or None,
103
- 'has_audio': bool(getattr(line, 'audio_path', None)),
104
- 'has_screenshot': bool(getattr(line, 'screenshot_path', None))
105
- })
106
- return jsonify({
107
- 'results': results,
108
- 'total': total_results,
109
- 'page': page,
110
- 'page_size': page_size,
111
- 'total_pages': (total_results + page_size - 1) // page_size
112
- }), 200
186
+ results.append(
187
+ {
188
+ "id": line.id,
189
+ "sentence": line.line_text or "",
190
+ "game_name": line.game_name or "Unknown Game",
191
+ "timestamp": float(line.timestamp)
192
+ if line.timestamp
193
+ else 0,
194
+ "translation": line.translation or None,
195
+ "has_audio": bool(getattr(line, "audio_path", None)),
196
+ "has_screenshot": bool(
197
+ getattr(line, "screenshot_path", None)
198
+ ),
199
+ }
200
+ )
201
+ return jsonify(
202
+ {
203
+ "results": results,
204
+ "total": total_results,
205
+ "page": page,
206
+ "page_size": page_size,
207
+ "total_pages": (total_results + page_size - 1) // page_size,
208
+ }
209
+ ), 200
113
210
  except Exception as e:
114
211
  logger.error(f"Regex search failed: {e}")
115
- return jsonify({'error': f'Search failed: {str(e)}'}), 500
212
+ return jsonify({"error": f"Search failed: {str(e)}"}), 500
116
213
  else:
117
214
  # Build the SQL query
118
- base_query = f"SELECT * FROM {GameLinesTable._table} WHERE line_text LIKE ?"
119
- params = [f'%{query}%']
120
-
215
+ base_query = (
216
+ f"SELECT * FROM {GameLinesTable._table} WHERE line_text LIKE ?"
217
+ )
218
+ params = [f"%{query}%"]
219
+
121
220
  # Add game filter if specified
122
221
  if game_filter:
123
222
  base_query += " AND game_name = ?"
124
223
  params.append(game_filter)
125
224
 
225
+ # Add date range filter if specified
226
+ if date_start_timestamp is not None:
227
+ base_query += " AND timestamp >= ?"
228
+ params.append(date_start_timestamp)
229
+ if date_end_timestamp is not None:
230
+ base_query += " AND timestamp <= ?"
231
+ params.append(date_end_timestamp)
232
+
126
233
  # Add sorting
127
- if sort_by == 'date_desc':
234
+ if sort_by == "date_desc":
128
235
  base_query += " ORDER BY timestamp DESC"
129
- elif sort_by == 'date_asc':
236
+ elif sort_by == "date_asc":
130
237
  base_query += " ORDER BY timestamp ASC"
131
- elif sort_by == 'game_name':
238
+ elif sort_by == "game_name":
132
239
  base_query += " ORDER BY game_name, timestamp DESC"
240
+ elif sort_by == "length_desc":
241
+ base_query += " ORDER BY LENGTH(line_text) DESC"
242
+ elif sort_by == "length_asc":
243
+ base_query += " ORDER BY LENGTH(line_text) ASC"
133
244
  else: # relevance - could be enhanced with proper scoring
134
245
  base_query += " ORDER BY timestamp DESC"
135
-
246
+
136
247
  # Get total count for pagination
137
248
  count_query = f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE line_text LIKE ?"
138
- count_params = [f'%{query}%']
249
+ count_params = [f"%{query}%"]
139
250
  if game_filter:
140
251
  count_query += " AND game_name = ?"
141
252
  count_params.append(game_filter)
142
-
143
- total_results = GameLinesTable._db.fetchone(count_query, count_params)[0]
144
-
253
+ if date_start_timestamp is not None:
254
+ count_query += " AND timestamp >= ?"
255
+ count_params.append(date_start_timestamp)
256
+ if date_end_timestamp is not None:
257
+ count_query += " AND timestamp <= ?"
258
+ count_params.append(date_end_timestamp)
259
+
260
+ total_results = GameLinesTable._db.fetchone(count_query, count_params)[
261
+ 0
262
+ ]
263
+
145
264
  # Add pagination
146
265
  offset = (page - 1) * page_size
147
266
  base_query += f" LIMIT ? OFFSET ?"
148
267
  params.extend([page_size, offset])
149
-
268
+
150
269
  # Execute search query
151
270
  rows = GameLinesTable._db.fetchall(base_query, params)
152
-
271
+
153
272
  # Format results
154
273
  results = []
155
274
  for row in rows:
156
275
  game_line = GameLinesTable.from_row(row)
157
276
  if game_line:
158
- results.append({
159
- 'id': game_line.id,
160
- 'sentence': game_line.line_text or '',
161
- 'game_name': game_line.game_name or 'Unknown Game',
162
- 'timestamp': float(game_line.timestamp) if game_line.timestamp else 0,
163
- 'translation': game_line.translation or None,
164
- 'has_audio': bool(game_line.audio_path),
165
- 'has_screenshot': bool(game_line.screenshot_path)
166
- })
167
-
168
- return jsonify({
169
- 'results': results,
170
- 'total': total_results,
171
- 'page': page,
172
- 'page_size': page_size,
173
- 'total_pages': (total_results + page_size - 1) // page_size
174
- }), 200
175
-
277
+ results.append(
278
+ {
279
+ "id": game_line.id,
280
+ "sentence": game_line.line_text or "",
281
+ "game_name": game_line.game_name or "Unknown Game",
282
+ "timestamp": float(game_line.timestamp)
283
+ if game_line.timestamp
284
+ else 0,
285
+ "translation": game_line.translation or None,
286
+ "has_audio": bool(game_line.audio_path),
287
+ "has_screenshot": bool(game_line.screenshot_path),
288
+ }
289
+ )
290
+
291
+ return jsonify(
292
+ {
293
+ "results": results,
294
+ "total": total_results,
295
+ "page": page,
296
+ "page_size": page_size,
297
+ "total_pages": (total_results + page_size - 1) // page_size,
298
+ }
299
+ ), 200
300
+
176
301
  except ValueError as e:
177
- return jsonify({'error': 'Invalid pagination parameters'}), 400
302
+ return jsonify({"error": "Invalid pagination parameters"}), 400
178
303
  except Exception as e:
179
304
  logger.error(f"Error in sentence search: {e}")
180
- return jsonify({'error': 'Search failed'}), 500
305
+ return jsonify({"error": "Search failed"}), 500
181
306
 
182
- @app.route('/api/games-list')
307
+ @app.route("/api/games-list")
183
308
  def api_games_list():
184
309
  """
185
310
  Provides game list with metadata for deletion interface.
@@ -187,335 +312,456 @@ def register_database_api_routes(app):
187
312
  try:
188
313
  game_names = GameLinesTable.get_all_games_with_lines()
189
314
  games_data = []
190
-
315
+
191
316
  for game_name in game_names:
192
317
  lines = GameLinesTable.get_all_lines_for_scene(game_name)
193
318
  if not lines:
194
319
  continue
195
-
320
+
196
321
  # Calculate metadata
197
322
  sentence_count = len(lines)
198
323
  timestamps = [float(line.timestamp) for line in lines]
199
324
  min_date = datetime.date.fromtimestamp(min(timestamps))
200
325
  max_date = datetime.date.fromtimestamp(max(timestamps))
201
- total_chars = sum(len(line.line_text) if line.line_text else 0 for line in lines)
202
-
203
- games_data.append({
204
- 'name': game_name,
205
- 'sentence_count': sentence_count,
206
- 'first_entry_date': min_date.strftime('%Y-%m-%d'),
207
- 'last_entry_date': max_date.strftime('%Y-%m-%d'),
208
- 'total_characters': total_chars,
209
- 'date_range': f"{min_date.strftime('%Y-%m-%d')} to {max_date.strftime('%Y-%m-%d')}" if min_date != max_date else min_date.strftime('%Y-%m-%d')
210
- })
211
-
326
+ total_chars = sum(
327
+ len(line.line_text) if line.line_text else 0 for line in lines
328
+ )
329
+
330
+ games_data.append(
331
+ {
332
+ "name": game_name,
333
+ "sentence_count": sentence_count,
334
+ "first_entry_date": min_date.strftime("%Y-%m-%d"),
335
+ "last_entry_date": max_date.strftime("%Y-%m-%d"),
336
+ "total_characters": total_chars,
337
+ "date_range": f"{min_date.strftime('%Y-%m-%d')} to {max_date.strftime('%Y-%m-%d')}"
338
+ if min_date != max_date
339
+ else min_date.strftime("%Y-%m-%d"),
340
+ }
341
+ )
342
+
212
343
  # Sort by total characters (most characters first)
213
- games_data.sort(key=lambda x: x['total_characters'], reverse=True)
214
-
215
- return jsonify({'games': games_data}), 200
216
-
344
+ games_data.sort(key=lambda x: x["total_characters"], reverse=True)
345
+
346
+ return jsonify({"games": games_data}), 200
347
+
217
348
  except Exception as e:
218
349
  logger.error(f"Error fetching games list: {e}", exc_info=True)
219
- return jsonify({'error': 'Failed to fetch games list'}), 500
350
+ return jsonify({"error": "Failed to fetch games list"}), 500
220
351
 
221
- @app.route('/api/delete-sentence-lines', methods=['POST'])
352
+ @app.route("/api/delete-sentence-lines", methods=["POST"])
222
353
  def api_delete_sentence_lines():
223
354
  """
224
355
  Delete specific sentence lines by their IDs.
225
356
  """
226
357
  try:
227
358
  data = request.get_json()
228
- line_ids = data.get('line_ids', [])
229
-
359
+ line_ids = data.get("line_ids", [])
360
+
230
361
  logger.debug(f"Request to delete line IDs: {line_ids}")
231
-
362
+
232
363
  if not line_ids:
233
- return jsonify({'error': 'No line IDs provided'}), 400
234
-
364
+ return jsonify({"error": "No line IDs provided"}), 400
365
+
235
366
  if not isinstance(line_ids, list):
236
- return jsonify({'error': 'line_ids must be a list'}), 400
237
-
367
+ return jsonify({"error": "line_ids must be a list"}), 400
368
+
238
369
  # Delete the lines
239
370
  deleted_count = 0
240
371
  failed_ids = []
241
-
372
+
242
373
  for line_id in line_ids:
243
374
  try:
244
375
  GameLinesTable._db.execute(
245
376
  f"DELETE FROM {GameLinesTable._table} WHERE id=?",
246
377
  (line_id,),
247
- commit=True
378
+ commit=True,
248
379
  )
249
380
  deleted_count += 1
250
381
  except Exception as e:
251
382
  logger.warning(f"Failed to delete line {line_id}: {e}")
252
383
  failed_ids.append(line_id)
253
-
254
- logger.info(f"Deleted {deleted_count} sentence lines out of {len(line_ids)} requested")
255
-
384
+
385
+ logger.info(
386
+ f"Deleted {deleted_count} sentence lines out of {len(line_ids)} requested"
387
+ )
388
+
256
389
  response_data = {
257
- 'deleted_count': deleted_count,
258
- 'message': f'Successfully deleted {deleted_count} {"sentence" if deleted_count == 1 else "sentences"}'
390
+ "deleted_count": deleted_count,
391
+ "message": f"Successfully deleted {deleted_count} {'sentence' if deleted_count == 1 else 'sentences'}",
259
392
  }
260
-
393
+
261
394
  if failed_ids:
262
- response_data['warning'] = f'{len(failed_ids)} lines failed to delete'
263
- response_data['failed_ids'] = failed_ids
264
-
395
+ response_data["warning"] = f"{len(failed_ids)} lines failed to delete"
396
+ response_data["failed_ids"] = failed_ids
397
+
398
+ # Trigger stats rollup after successful deletion
399
+ if deleted_count > 0:
400
+ try:
401
+ logger.info("Triggering stats rollup after sentence line deletion")
402
+ run_daily_rollup()
403
+ except Exception as rollup_error:
404
+ logger.error(f"Stats rollup failed after sentence line deletion: {rollup_error}")
405
+ # Don't fail the deletion operation if rollup fails
406
+
265
407
  return jsonify(response_data), 200
266
-
408
+
267
409
  except Exception as e:
268
410
  logger.error(f"Error in sentence line deletion: {e}")
269
- return jsonify({'error': f'Failed to delete sentences: {str(e)}'}), 500
411
+ return jsonify({"error": f"Failed to delete sentences: {str(e)}"}), 500
270
412
 
271
- @app.route('/api/delete-games', methods=['POST'])
413
+ @app.route("/api/delete-games", methods=["POST"])
272
414
  def api_delete_games():
273
415
  """
274
416
  Handles bulk deletion of games and their associated data.
275
417
  """
276
418
  try:
277
419
  data = request.get_json()
278
- game_names = data.get('game_names', [])
279
-
420
+ game_names = data.get("game_names", [])
421
+
280
422
  if not game_names:
281
- return jsonify({'error': 'No games specified for deletion'}), 400
282
-
423
+ return jsonify({"error": "No games specified for deletion"}), 400
424
+
283
425
  if not isinstance(game_names, list):
284
- return jsonify({'error': 'game_names must be a list'}), 400
285
-
426
+ return jsonify({"error": "game_names must be a list"}), 400
427
+
286
428
  # Validate that all games exist
287
429
  existing_games = GameLinesTable.get_all_games_with_lines()
288
430
  invalid_games = [name for name in game_names if name not in existing_games]
289
-
431
+
290
432
  if invalid_games:
291
- return jsonify({'error': f'Games not found: {", ".join(invalid_games)}'}), 400
292
-
433
+ return jsonify(
434
+ {"error": f"Games not found: {', '.join(invalid_games)}"}
435
+ ), 400
436
+
293
437
  deletion_results = {}
294
438
  total_deleted = 0
295
-
439
+
296
440
  # Delete each game's data
297
441
  for game_name in game_names:
298
442
  try:
299
443
  # Get lines for this game before deletion for counting
300
444
  lines = GameLinesTable.get_all_lines_for_scene(game_name)
301
445
  lines_count = len(lines)
302
-
446
+
303
447
  # Delete all lines for this game using the database connection
304
448
  GameLinesTable._db.execute(
305
449
  f"DELETE FROM {GameLinesTable._table} WHERE game_name=?",
306
450
  (game_name,),
307
- commit=True
451
+ commit=True,
308
452
  )
309
-
453
+
310
454
  deletion_results[game_name] = {
311
- 'deleted_sentences': lines_count,
312
- 'status': 'success'
455
+ "deleted_sentences": lines_count,
456
+ "status": "success",
313
457
  }
314
458
  total_deleted += lines_count
315
-
316
- logger.info(f"Deleted {lines_count} sentences for game: {game_name}")
317
-
459
+
460
+ logger.info(
461
+ f"Deleted {lines_count} sentences for game: {game_name}"
462
+ )
463
+
318
464
  except Exception as e:
319
465
  logger.error(f"Error deleting game {game_name}: {e}")
320
466
  deletion_results[game_name] = {
321
- 'deleted_sentences': 0,
322
- 'status': 'error',
323
- 'error': str(e)
467
+ "deleted_sentences": 0,
468
+ "status": "error",
469
+ "error": str(e),
324
470
  }
325
-
471
+
326
472
  # Check if any deletions were successful
327
- successful_deletions = [name for name, result in deletion_results.items() if result['status'] == 'success']
328
- failed_deletions = [name for name, result in deletion_results.items() if result['status'] == 'error']
329
-
473
+ successful_deletions = [
474
+ name
475
+ for name, result in deletion_results.items()
476
+ if result["status"] == "success"
477
+ ]
478
+ failed_deletions = [
479
+ name
480
+ for name, result in deletion_results.items()
481
+ if result["status"] == "error"
482
+ ]
483
+
330
484
  response_data = {
331
- 'message': f'Deletion completed. {len(successful_deletions)} games successfully deleted.',
332
- 'total_sentences_deleted': total_deleted,
333
- 'successful_games': successful_deletions,
334
- 'failed_games': failed_deletions,
335
- 'detailed_results': deletion_results
485
+ "message": f"Deletion completed. {len(successful_deletions)} games successfully deleted.",
486
+ "total_sentences_deleted": total_deleted,
487
+ "successful_games": successful_deletions,
488
+ "failed_games": failed_deletions,
489
+ "detailed_results": deletion_results,
336
490
  }
337
-
491
+
338
492
  if failed_deletions:
339
- response_data['warning'] = f'Some games failed to delete: {", ".join(failed_deletions)}'
340
- return jsonify(response_data), 207 # Multi-Status (partial success)
493
+ response_data["warning"] = (
494
+ f"Some games failed to delete: {', '.join(failed_deletions)}"
495
+ )
496
+ status_code = 207 # Multi-Status (partial success)
341
497
  else:
342
- return jsonify(response_data), 200
343
-
498
+ status_code = 200
499
+
500
+ # Trigger stats rollup after successful deletion
501
+ if successful_deletions:
502
+ try:
503
+ logger.info("Triggering stats rollup after game deletion")
504
+ run_daily_rollup()
505
+ except Exception as rollup_error:
506
+ logger.error(f"Stats rollup failed after game deletion: {rollup_error}")
507
+ # Don't fail the deletion operation if rollup fails
508
+
509
+ return jsonify(response_data), status_code
510
+
344
511
  except Exception as e:
345
512
  logger.error(f"Error in bulk game deletion: {e}")
346
- return jsonify({'error': f'Failed to delete games: {str(e)}'}), 500
513
+ return jsonify({"error": f"Failed to delete games: {str(e)}"}), 500
347
514
 
348
- @app.route('/api/settings', methods=['GET'])
515
+ @app.route("/api/settings", methods=["GET"])
349
516
  def api_get_settings():
350
517
  """
351
518
  Get current AFK timer, session gap, streak requirement, and goal settings.
352
519
  """
353
520
  try:
354
521
  config = get_stats_config()
355
- return jsonify({
356
- 'afk_timer_seconds': config.afk_timer_seconds,
357
- 'session_gap_seconds': config.session_gap_seconds,
358
- 'streak_requirement_hours': config.streak_requirement_hours,
359
- 'reading_hours_target': config.reading_hours_target,
360
- 'character_count_target': config.character_count_target,
361
- 'games_target': config.games_target,
362
- 'reading_hours_target_date': config.reading_hours_target_date,
363
- 'character_count_target_date': config.character_count_target_date,
364
- 'games_target_date': config.games_target_date,
365
- }), 200
522
+ return jsonify(
523
+ {
524
+ "afk_timer_seconds": config.afk_timer_seconds,
525
+ "session_gap_seconds": config.session_gap_seconds,
526
+ "streak_requirement_hours": config.streak_requirement_hours,
527
+ "reading_hours_target": config.reading_hours_target,
528
+ "character_count_target": config.character_count_target,
529
+ "games_target": config.games_target,
530
+ "reading_hours_target_date": config.reading_hours_target_date,
531
+ "character_count_target_date": config.character_count_target_date,
532
+ "games_target_date": config.games_target_date,
533
+ "cards_mined_daily_target": getattr(config, 'cards_mined_daily_target', 10),
534
+ }
535
+ ), 200
366
536
  except Exception as e:
367
537
  logger.error(f"Error getting settings: {e}")
368
- return jsonify({'error': 'Failed to get settings'}), 500
538
+ return jsonify({"error": "Failed to get settings"}), 500
369
539
 
370
- @app.route('/api/settings', methods=['POST'])
540
+ @app.route("/api/settings", methods=["POST"])
371
541
  def api_save_settings():
372
542
  """
373
543
  Save/update AFK timer, session gap, streak requirement, and goal settings.
374
544
  """
375
545
  try:
376
546
  data = request.get_json()
377
-
547
+
378
548
  if not data:
379
- return jsonify({'error': 'No data provided'}), 400
380
-
381
- afk_timer = data.get('afk_timer_seconds')
382
- session_gap = data.get('session_gap_seconds')
383
- streak_requirement = data.get('streak_requirement_hours')
384
- reading_hours_target = data.get('reading_hours_target')
385
- character_count_target = data.get('character_count_target')
386
- games_target = data.get('games_target')
387
- reading_hours_target_date = data.get('reading_hours_target_date')
388
- character_count_target_date = data.get('character_count_target_date')
389
- games_target_date = data.get('games_target_date')
390
-
549
+ return jsonify({"error": "No data provided"}), 400
550
+
551
+ afk_timer = data.get("afk_timer_seconds")
552
+ session_gap = data.get("session_gap_seconds")
553
+ streak_requirement = data.get("streak_requirement_hours")
554
+ reading_hours_target = data.get("reading_hours_target")
555
+ character_count_target = data.get("character_count_target")
556
+ games_target = data.get("games_target")
557
+ reading_hours_target_date = data.get("reading_hours_target_date")
558
+ character_count_target_date = data.get("character_count_target_date")
559
+ games_target_date = data.get("games_target_date")
560
+ cards_mined_daily_target = data.get("cards_mined_daily_target")
561
+
391
562
  # Validate input - only require the settings that are provided
392
563
  settings_to_update = {}
393
-
564
+
394
565
  if afk_timer is not None:
395
566
  try:
396
567
  afk_timer = int(afk_timer)
397
568
  if afk_timer < 0 or afk_timer > 600:
398
- return jsonify({'error': 'AFK timer must be between 0 and 600 seconds'}), 400
399
- settings_to_update['afk_timer_seconds'] = afk_timer
569
+ return jsonify(
570
+ {"error": "AFK timer must be between 0 and 600 seconds"}
571
+ ), 400
572
+ settings_to_update["afk_timer_seconds"] = afk_timer
400
573
  except (ValueError, TypeError):
401
- return jsonify({'error': 'AFK timer must be a valid integer'}), 400
402
-
574
+ return jsonify({"error": "AFK timer must be a valid integer"}), 400
575
+
403
576
  if session_gap is not None:
404
577
  try:
405
578
  session_gap = int(session_gap)
406
579
  if session_gap < 0 or session_gap > 7200:
407
- return jsonify({'error': 'Session gap must be between 0 and 7200 seconds (0 to 2 hours)'}), 400
408
- settings_to_update['session_gap_seconds'] = session_gap
580
+ return jsonify(
581
+ {
582
+ "error": "Session gap must be between 0 and 7200 seconds (0 to 2 hours)"
583
+ }
584
+ ), 400
585
+ settings_to_update["session_gap_seconds"] = session_gap
409
586
  except (ValueError, TypeError):
410
- return jsonify({'error': 'Session gap must be a valid integer'}), 400
411
-
587
+ return jsonify(
588
+ {"error": "Session gap must be a valid integer"}
589
+ ), 400
590
+
412
591
  if streak_requirement is not None:
413
592
  try:
414
593
  streak_requirement = float(streak_requirement)
415
594
  if streak_requirement < 0.01 or streak_requirement > 24:
416
- return jsonify({'error': 'Streak requirement must be between 0.01 and 24 hours'}), 400
417
- settings_to_update['streak_requirement_hours'] = streak_requirement
595
+ return jsonify(
596
+ {
597
+ "error": "Streak requirement must be between 0.01 and 24 hours"
598
+ }
599
+ ), 400
600
+ settings_to_update["streak_requirement_hours"] = streak_requirement
418
601
  except (ValueError, TypeError):
419
- return jsonify({'error': 'Streak requirement must be a valid number'}), 400
420
-
602
+ return jsonify(
603
+ {"error": "Streak requirement must be a valid number"}
604
+ ), 400
605
+
421
606
  if reading_hours_target is not None:
422
607
  try:
423
608
  reading_hours_target = int(reading_hours_target)
424
609
  if reading_hours_target < 1 or reading_hours_target > 10000:
425
- return jsonify({'error': 'Reading hours target must be between 1 and 10,000 hours'}), 400
426
- settings_to_update['reading_hours_target'] = reading_hours_target
610
+ return jsonify(
611
+ {
612
+ "error": "Reading hours target must be between 1 and 10,000 hours"
613
+ }
614
+ ), 400
615
+ settings_to_update["reading_hours_target"] = reading_hours_target
427
616
  except (ValueError, TypeError):
428
- return jsonify({'error': 'Reading hours target must be a valid integer'}), 400
429
-
617
+ return jsonify(
618
+ {"error": "Reading hours target must be a valid integer"}
619
+ ), 400
620
+
430
621
  if character_count_target is not None:
431
622
  try:
432
623
  character_count_target = int(character_count_target)
433
- if character_count_target < 1000 or character_count_target > 1000000000:
434
- return jsonify({'error': 'Character count target must be between 1,000 and 1,000,000,000 characters'}), 400
435
- settings_to_update['character_count_target'] = character_count_target
624
+ if (
625
+ character_count_target < 1000
626
+ or character_count_target > 1000000000
627
+ ):
628
+ return jsonify(
629
+ {
630
+ "error": "Character count target must be between 1,000 and 1,000,000,000 characters"
631
+ }
632
+ ), 400
633
+ settings_to_update["character_count_target"] = (
634
+ character_count_target
635
+ )
436
636
  except (ValueError, TypeError):
437
- return jsonify({'error': 'Character count target must be a valid integer'}), 400
438
-
637
+ return jsonify(
638
+ {"error": "Character count target must be a valid integer"}
639
+ ), 400
640
+
439
641
  if games_target is not None:
440
642
  try:
441
643
  games_target = int(games_target)
442
644
  if games_target < 1 or games_target > 1000:
443
- return jsonify({'error': 'Games target must be between 1 and 1,000'}), 400
444
- settings_to_update['games_target'] = games_target
645
+ return jsonify(
646
+ {"error": "Games target must be between 1 and 1,000"}
647
+ ), 400
648
+ settings_to_update["games_target"] = games_target
445
649
  except (ValueError, TypeError):
446
- return jsonify({'error': 'Games target must be a valid integer'}), 400
447
-
650
+ return jsonify(
651
+ {"error": "Games target must be a valid integer"}
652
+ ), 400
653
+
448
654
  # Validate target dates (ISO format: YYYY-MM-DD)
449
655
  if reading_hours_target_date is not None:
450
- if reading_hours_target_date == '':
451
- settings_to_update['reading_hours_target_date'] = ''
656
+ if reading_hours_target_date == "":
657
+ settings_to_update["reading_hours_target_date"] = ""
452
658
  else:
453
659
  try:
454
- datetime.datetime.strptime(reading_hours_target_date, '%Y-%m-%d')
455
- settings_to_update['reading_hours_target_date'] = reading_hours_target_date
660
+ datetime.datetime.strptime(
661
+ reading_hours_target_date, "%Y-%m-%d"
662
+ )
663
+ settings_to_update["reading_hours_target_date"] = (
664
+ reading_hours_target_date
665
+ )
456
666
  except ValueError:
457
- return jsonify({'error': 'Reading hours target date must be in YYYY-MM-DD format'}), 400
458
-
667
+ return jsonify(
668
+ {
669
+ "error": "Reading hours target date must be in YYYY-MM-DD format"
670
+ }
671
+ ), 400
672
+
459
673
  if character_count_target_date is not None:
460
- if character_count_target_date == '':
461
- settings_to_update['character_count_target_date'] = ''
674
+ if character_count_target_date == "":
675
+ settings_to_update["character_count_target_date"] = ""
462
676
  else:
463
677
  try:
464
- datetime.datetime.strptime(character_count_target_date, '%Y-%m-%d')
465
- settings_to_update['character_count_target_date'] = character_count_target_date
678
+ datetime.datetime.strptime(
679
+ character_count_target_date, "%Y-%m-%d"
680
+ )
681
+ settings_to_update["character_count_target_date"] = (
682
+ character_count_target_date
683
+ )
466
684
  except ValueError:
467
- return jsonify({'error': 'Character count target date must be in YYYY-MM-DD format'}), 400
468
-
685
+ return jsonify(
686
+ {
687
+ "error": "Character count target date must be in YYYY-MM-DD format"
688
+ }
689
+ ), 400
690
+
469
691
  if games_target_date is not None:
470
- if games_target_date == '':
471
- settings_to_update['games_target_date'] = ''
692
+ if games_target_date == "":
693
+ settings_to_update["games_target_date"] = ""
472
694
  else:
473
695
  try:
474
- datetime.datetime.strptime(games_target_date, '%Y-%m-%d')
475
- settings_to_update['games_target_date'] = games_target_date
696
+ datetime.datetime.strptime(games_target_date, "%Y-%m-%d")
697
+ settings_to_update["games_target_date"] = games_target_date
476
698
  except ValueError:
477
- return jsonify({'error': 'Games target date must be in YYYY-MM-DD format'}), 400
478
-
699
+ return jsonify(
700
+ {"error": "Games target date must be in YYYY-MM-DD format"}
701
+ ), 400
702
+
703
+ if cards_mined_daily_target is not None:
704
+ try:
705
+ cards_mined_daily_target = int(cards_mined_daily_target)
706
+ if cards_mined_daily_target < 0 or cards_mined_daily_target > 1000:
707
+ return jsonify(
708
+ {"error": "Cards mined daily target must be between 0 and 1,000"}
709
+ ), 400
710
+ settings_to_update["cards_mined_daily_target"] = cards_mined_daily_target
711
+ except (ValueError, TypeError):
712
+ return jsonify(
713
+ {"error": "Cards mined daily target must be a valid integer"}
714
+ ), 400
715
+
479
716
  if not settings_to_update:
480
- return jsonify({'error': 'No valid settings provided'}), 400
481
-
717
+ return jsonify({"error": "No valid settings provided"}), 400
718
+
482
719
  # Update configuration
483
720
  config = get_stats_config()
484
-
485
- if 'afk_timer_seconds' in settings_to_update:
486
- config.afk_timer_seconds = settings_to_update['afk_timer_seconds']
487
- if 'session_gap_seconds' in settings_to_update:
488
- config.session_gap_seconds = settings_to_update['session_gap_seconds']
489
- if 'streak_requirement_hours' in settings_to_update:
490
- config.streak_requirement_hours = settings_to_update['streak_requirement_hours']
491
- if 'reading_hours_target' in settings_to_update:
492
- config.reading_hours_target = settings_to_update['reading_hours_target']
493
- if 'character_count_target' in settings_to_update:
494
- config.character_count_target = settings_to_update['character_count_target']
495
- if 'games_target' in settings_to_update:
496
- config.games_target = settings_to_update['games_target']
497
- if 'reading_hours_target_date' in settings_to_update:
498
- config.reading_hours_target_date = settings_to_update['reading_hours_target_date']
499
- if 'character_count_target_date' in settings_to_update:
500
- config.character_count_target_date = settings_to_update['character_count_target_date']
501
- if 'games_target_date' in settings_to_update:
502
- config.games_target_date = settings_to_update['games_target_date']
503
-
721
+
722
+ if "afk_timer_seconds" in settings_to_update:
723
+ config.afk_timer_seconds = settings_to_update["afk_timer_seconds"]
724
+ if "session_gap_seconds" in settings_to_update:
725
+ config.session_gap_seconds = settings_to_update["session_gap_seconds"]
726
+ if "streak_requirement_hours" in settings_to_update:
727
+ config.streak_requirement_hours = settings_to_update[
728
+ "streak_requirement_hours"
729
+ ]
730
+ if "reading_hours_target" in settings_to_update:
731
+ config.reading_hours_target = settings_to_update["reading_hours_target"]
732
+ if "character_count_target" in settings_to_update:
733
+ config.character_count_target = settings_to_update[
734
+ "character_count_target"
735
+ ]
736
+ if "games_target" in settings_to_update:
737
+ config.games_target = settings_to_update["games_target"]
738
+ if "reading_hours_target_date" in settings_to_update:
739
+ config.reading_hours_target_date = settings_to_update[
740
+ "reading_hours_target_date"
741
+ ]
742
+ if "character_count_target_date" in settings_to_update:
743
+ config.character_count_target_date = settings_to_update[
744
+ "character_count_target_date"
745
+ ]
746
+ if "games_target_date" in settings_to_update:
747
+ config.games_target_date = settings_to_update["games_target_date"]
748
+ if "cards_mined_daily_target" in settings_to_update:
749
+ config.cards_mined_daily_target = settings_to_update["cards_mined_daily_target"]
750
+
504
751
  save_stats_config(config)
505
752
 
506
753
  logger.info(f"Settings updated: {settings_to_update}")
507
-
508
- response_data = {'message': 'Settings saved successfully'}
754
+
755
+ response_data = {"message": "Settings saved successfully"}
509
756
  response_data.update(settings_to_update)
510
-
757
+
511
758
  return jsonify(response_data), 200
512
-
759
+
513
760
  except Exception as e:
514
761
  logger.error(f"Error saving settings: {e}")
515
- return jsonify({'error': 'Failed to save settings'}), 500
762
+ return jsonify({"error": "Failed to save settings"}), 500
516
763
 
517
-
518
- @app.route('/api/preview-text-deletion', methods=['POST'])
764
+ @app.route("/api/preview-text-deletion", methods=["POST"])
519
765
  def api_preview_text_deletion():
520
766
  """
521
767
  Preview text lines that would be deleted based on regex or exact text matching.
@@ -523,40 +769,46 @@ def register_database_api_routes(app):
523
769
  try:
524
770
  data = request.get_json()
525
771
  if not data:
526
- return jsonify({'error': 'No data provided'}), 400
527
-
528
- regex_pattern = data.get('regex_pattern')
529
- exact_text = data.get('exact_text')
530
- case_sensitive = data.get('case_sensitive', False)
531
- use_regex = data.get('use_regex', False)
532
-
772
+ return jsonify({"error": "No data provided"}), 400
773
+
774
+ regex_pattern = data.get("regex_pattern")
775
+ exact_text = data.get("exact_text")
776
+ case_sensitive = data.get("case_sensitive", False)
777
+ use_regex = data.get("use_regex", False)
778
+
533
779
  if not regex_pattern and not exact_text:
534
- return jsonify({'error': 'Either regex_pattern or exact_text must be provided'}), 400
535
-
780
+ return jsonify(
781
+ {"error": "Either regex_pattern or exact_text must be provided"}
782
+ ), 400
783
+
536
784
  # Get all lines from database
537
785
  all_lines = GameLinesTable.all()
538
786
  if not all_lines:
539
- return jsonify({'count': 0, 'samples': []}), 200
540
-
787
+ return jsonify({"count": 0, "samples": []}), 200
788
+
541
789
  matches = []
542
-
790
+
543
791
  if regex_pattern and use_regex:
544
792
  # Use regex matching
545
793
  try:
546
794
  # Ensure regex_pattern is a string
547
795
  if not isinstance(regex_pattern, str):
548
- return jsonify({'error': 'Regex pattern must be a string'}), 400
549
-
796
+ return jsonify({"error": "Regex pattern must be a string"}), 400
797
+
550
798
  flags = 0 if case_sensitive else re.IGNORECASE
551
799
  pattern = re.compile(regex_pattern, flags)
552
-
800
+
553
801
  for line in all_lines:
554
- if line.line_text and isinstance(line.line_text, str) and pattern.search(line.line_text):
802
+ if (
803
+ line.line_text
804
+ and isinstance(line.line_text, str)
805
+ and pattern.search(line.line_text)
806
+ ):
555
807
  matches.append(line.line_text)
556
-
808
+
557
809
  except re.error as e:
558
- return jsonify({'error': f'Invalid regex pattern: {str(e)}'}), 400
559
-
810
+ return jsonify({"error": f"Invalid regex pattern: {str(e)}"}), 400
811
+
560
812
  elif exact_text:
561
813
  # Use exact text matching - ensure exact_text is properly handled
562
814
  if isinstance(exact_text, list):
@@ -564,21 +816,27 @@ def register_database_api_routes(app):
564
816
  elif isinstance(exact_text, str):
565
817
  text_lines = [exact_text]
566
818
  else:
567
- return jsonify({'error': 'exact_text must be a string or list of strings'}), 400
568
-
819
+ return jsonify(
820
+ {"error": "exact_text must be a string or list of strings"}
821
+ ), 400
822
+
569
823
  for line in all_lines:
570
824
  if line.line_text and isinstance(line.line_text, str):
571
- line_text = line.line_text if case_sensitive else line.line_text.lower()
572
-
825
+ line_text = (
826
+ line.line_text if case_sensitive else line.line_text.lower()
827
+ )
828
+
573
829
  for target_text in text_lines:
574
830
  # Ensure target_text is a string
575
831
  if not isinstance(target_text, str):
576
832
  continue
577
- compare_text = target_text if case_sensitive else target_text.lower()
833
+ compare_text = (
834
+ target_text if case_sensitive else target_text.lower()
835
+ )
578
836
  if compare_text in line_text:
579
837
  matches.append(line.line_text)
580
838
  break
581
-
839
+
582
840
  # Remove duplicates while preserving order
583
841
  unique_matches = []
584
842
  seen = set()
@@ -586,20 +844,17 @@ def register_database_api_routes(app):
586
844
  if match not in seen:
587
845
  unique_matches.append(match)
588
846
  seen.add(match)
589
-
847
+
590
848
  # Get sample matches (first 10)
591
849
  samples = unique_matches[:10]
592
-
593
- return jsonify({
594
- 'count': len(unique_matches),
595
- 'samples': samples
596
- }), 200
597
-
850
+
851
+ return jsonify({"count": len(unique_matches), "samples": samples}), 200
852
+
598
853
  except Exception as e:
599
854
  logger.error(f"Error in preview text deletion: {e}")
600
- return jsonify({'error': f'Preview failed: {str(e)}'}), 500
855
+ return jsonify({"error": f"Preview failed: {str(e)}"}), 500
601
856
 
602
- @app.route('/api/delete-text-lines', methods=['POST'])
857
+ @app.route("/api/delete-text-lines", methods=["POST"])
603
858
  def api_delete_text_lines():
604
859
  """
605
860
  Delete text lines from database based on regex or exact text matching.
@@ -607,40 +862,46 @@ def register_database_api_routes(app):
607
862
  try:
608
863
  data = request.get_json()
609
864
  if not data:
610
- return jsonify({'error': 'No data provided'}), 400
611
-
612
- regex_pattern = data.get('regex_pattern')
613
- exact_text = data.get('exact_text')
614
- case_sensitive = data.get('case_sensitive', False)
615
- use_regex = data.get('use_regex', False)
616
-
865
+ return jsonify({"error": "No data provided"}), 400
866
+
867
+ regex_pattern = data.get("regex_pattern")
868
+ exact_text = data.get("exact_text")
869
+ case_sensitive = data.get("case_sensitive", False)
870
+ use_regex = data.get("use_regex", False)
871
+
617
872
  if not regex_pattern and not exact_text:
618
- return jsonify({'error': 'Either regex_pattern or exact_text must be provided'}), 400
619
-
873
+ return jsonify(
874
+ {"error": "Either regex_pattern or exact_text must be provided"}
875
+ ), 400
876
+
620
877
  # Get all lines from database
621
878
  all_lines = GameLinesTable.all()
622
879
  if not all_lines:
623
- return jsonify({'deleted_count': 0}), 200
624
-
880
+ return jsonify({"deleted_count": 0}), 200
881
+
625
882
  lines_to_delete = []
626
-
883
+
627
884
  if regex_pattern and use_regex:
628
885
  # Use regex matching
629
886
  try:
630
887
  # Ensure regex_pattern is a string
631
888
  if not isinstance(regex_pattern, str):
632
- return jsonify({'error': 'Regex pattern must be a string'}), 400
633
-
889
+ return jsonify({"error": "Regex pattern must be a string"}), 400
890
+
634
891
  flags = 0 if case_sensitive else re.IGNORECASE
635
892
  pattern = re.compile(regex_pattern, flags)
636
-
893
+
637
894
  for line in all_lines:
638
- if line.line_text and isinstance(line.line_text, str) and pattern.search(line.line_text):
895
+ if (
896
+ line.line_text
897
+ and isinstance(line.line_text, str)
898
+ and pattern.search(line.line_text)
899
+ ):
639
900
  lines_to_delete.append(line.id)
640
-
901
+
641
902
  except re.error as e:
642
- return jsonify({'error': f'Invalid regex pattern: {str(e)}'}), 400
643
-
903
+ return jsonify({"error": f"Invalid regex pattern: {str(e)}"}), 400
904
+
644
905
  elif exact_text:
645
906
  # Use exact text matching - ensure exact_text is properly handled
646
907
  if isinstance(exact_text, list):
@@ -648,21 +909,27 @@ def register_database_api_routes(app):
648
909
  elif isinstance(exact_text, str):
649
910
  text_lines = [exact_text]
650
911
  else:
651
- return jsonify({'error': 'exact_text must be a string or list of strings'}), 400
652
-
912
+ return jsonify(
913
+ {"error": "exact_text must be a string or list of strings"}
914
+ ), 400
915
+
653
916
  for line in all_lines:
654
917
  if line.line_text and isinstance(line.line_text, str):
655
- line_text = line.line_text if case_sensitive else line.line_text.lower()
656
-
918
+ line_text = (
919
+ line.line_text if case_sensitive else line.line_text.lower()
920
+ )
921
+
657
922
  for target_text in text_lines:
658
923
  # Ensure target_text is a string
659
924
  if not isinstance(target_text, str):
660
925
  continue
661
- compare_text = target_text if case_sensitive else target_text.lower()
926
+ compare_text = (
927
+ target_text if case_sensitive else target_text.lower()
928
+ )
662
929
  if compare_text in line_text:
663
930
  lines_to_delete.append(line.id)
664
931
  break
665
-
932
+
666
933
  # Delete the matching lines
667
934
  deleted_count = 0
668
935
  for line_id in set(lines_to_delete): # Remove duplicates
@@ -670,24 +937,37 @@ def register_database_api_routes(app):
670
937
  GameLinesTable._db.execute(
671
938
  f"DELETE FROM {GameLinesTable._table} WHERE id=?",
672
939
  (line_id,),
673
- commit=True
940
+ commit=True,
674
941
  )
675
942
  deleted_count += 1
676
943
  except Exception as e:
677
944
  logger.warning(f"Failed to delete line {line_id}: {e}")
678
-
679
- logger.info(f"Deleted {deleted_count} lines using pattern: {regex_pattern or exact_text}")
680
-
681
- return jsonify({
682
- 'deleted_count': deleted_count,
683
- 'message': f'Successfully deleted {deleted_count} lines'
684
- }), 200
685
-
945
+
946
+ logger.info(
947
+ f"Deleted {deleted_count} lines using pattern: {regex_pattern or exact_text}"
948
+ )
949
+
950
+ # Trigger stats rollup after successful deletion
951
+ if deleted_count > 0:
952
+ try:
953
+ logger.info("Triggering stats rollup after text line deletion")
954
+ run_daily_rollup()
955
+ except Exception as rollup_error:
956
+ logger.error(f"Stats rollup failed after text line deletion: {rollup_error}")
957
+ # Don't fail the deletion operation if rollup fails
958
+
959
+ return jsonify(
960
+ {
961
+ "deleted_count": deleted_count,
962
+ "message": f"Successfully deleted {deleted_count} lines",
963
+ }
964
+ ), 200
965
+
686
966
  except Exception as e:
687
967
  logger.error(f"Error in delete text lines: {e}")
688
- return jsonify({'error': f'Deletion failed: {str(e)}'}), 500
968
+ return jsonify({"error": f"Deletion failed: {str(e)}"}), 500
689
969
 
690
- @app.route('/api/preview-deduplication', methods=['POST'])
970
+ @app.route("/api/preview-deduplication", methods=["POST"])
691
971
  def api_preview_deduplication():
692
972
  """
693
973
  Preview duplicate sentences that would be removed based on time window and game selection.
@@ -696,42 +976,44 @@ def register_database_api_routes(app):
696
976
  try:
697
977
  data = request.get_json()
698
978
  if not data:
699
- return jsonify({'error': 'No data provided'}), 400
700
-
701
- games = data.get('games', [])
702
- time_window_minutes = data.get('time_window_minutes', 5)
703
- case_sensitive = data.get('case_sensitive', False)
704
- ignore_time_window = data.get('ignore_time_window', False)
705
-
979
+ return jsonify({"error": "No data provided"}), 400
980
+
981
+ games = data.get("games", [])
982
+ time_window_minutes = data.get("time_window_minutes", 5)
983
+ case_sensitive = data.get("case_sensitive", False)
984
+ ignore_time_window = data.get("ignore_time_window", False)
985
+
706
986
  if not games:
707
- return jsonify({'error': 'At least one game must be selected'}), 400
708
-
987
+ return jsonify({"error": "At least one game must be selected"}), 400
988
+
709
989
  # Get lines from selected games
710
- if 'all' in games:
990
+ if "all" in games:
711
991
  all_lines = GameLinesTable.all()
712
992
  else:
713
993
  all_lines = []
714
994
  for game_name in games:
715
995
  game_lines = GameLinesTable.get_all_lines_for_scene(game_name)
716
996
  all_lines.extend(game_lines)
717
-
997
+
718
998
  if not all_lines:
719
- return jsonify({'duplicates_count': 0, 'games_affected': 0, 'samples': []}), 200
720
-
999
+ return jsonify(
1000
+ {"duplicates_count": 0, "games_affected": 0, "samples": []}
1001
+ ), 200
1002
+
721
1003
  # Group lines by game and sort by timestamp
722
1004
  game_lines = defaultdict(list)
723
1005
  for line in all_lines:
724
1006
  game_name = line.game_name or "Unknown Game"
725
1007
  game_lines[game_name].append(line)
726
-
1008
+
727
1009
  # Sort lines within each game by timestamp
728
1010
  for game_name in game_lines:
729
1011
  game_lines[game_name].sort(key=lambda x: float(x.timestamp))
730
-
1012
+
731
1013
  duplicates_to_remove = []
732
1014
  duplicate_samples = {}
733
1015
  time_window_seconds = time_window_minutes * 60
734
-
1016
+
735
1017
  # Find duplicates for each game
736
1018
  for game_name, lines in game_lines.items():
737
1019
  if ignore_time_window:
@@ -740,73 +1022,85 @@ def register_database_api_routes(app):
740
1022
  for line in lines:
741
1023
  if not line.line_text or not line.line_text.strip():
742
1024
  continue
743
-
744
- line_text = line.line_text if case_sensitive else line.line_text.lower()
745
-
1025
+
1026
+ line_text = (
1027
+ line.line_text if case_sensitive else line.line_text.lower()
1028
+ )
1029
+
746
1030
  if line_text in seen_texts:
747
1031
  # Found duplicate
748
1032
  duplicates_to_remove.append(line.id)
749
-
1033
+
750
1034
  # Store sample for preview
751
1035
  if line_text not in duplicate_samples:
752
1036
  duplicate_samples[line_text] = {
753
- 'text': line.line_text, # Original case
754
- 'occurrences': 1
1037
+ "text": line.line_text, # Original case
1038
+ "occurrences": 1,
755
1039
  }
756
- duplicate_samples[line_text]['occurrences'] += 1
1040
+ duplicate_samples[line_text]["occurrences"] += 1
757
1041
  else:
758
1042
  seen_texts[line_text] = line.id
759
1043
  else:
760
1044
  # Find duplicates within time window (original logic)
761
1045
  text_timeline = []
762
-
1046
+
763
1047
  for line in lines:
764
1048
  if not line.line_text or not line.line_text.strip():
765
1049
  continue
766
-
767
- line_text = line.line_text if case_sensitive else line.line_text.lower()
1050
+
1051
+ line_text = (
1052
+ line.line_text if case_sensitive else line.line_text.lower()
1053
+ )
768
1054
  timestamp = float(line.timestamp)
769
-
1055
+
770
1056
  # Check for duplicates within time window
771
- for prev_text, prev_timestamp, prev_line_id in reversed(text_timeline):
1057
+ for prev_text, prev_timestamp, prev_line_id in reversed(
1058
+ text_timeline
1059
+ ):
772
1060
  if timestamp - prev_timestamp > time_window_seconds:
773
1061
  break # Outside time window
774
-
1062
+
775
1063
  if prev_text == line_text:
776
1064
  # Found duplicate within time window
777
1065
  duplicates_to_remove.append(line.id)
778
-
1066
+
779
1067
  # Store sample for preview
780
1068
  if line_text not in duplicate_samples:
781
1069
  duplicate_samples[line_text] = {
782
- 'text': line.line_text, # Original case
783
- 'occurrences': 1
1070
+ "text": line.line_text, # Original case
1071
+ "occurrences": 1,
784
1072
  }
785
- duplicate_samples[line_text]['occurrences'] += 1
1073
+ duplicate_samples[line_text]["occurrences"] += 1
786
1074
  break
787
-
1075
+
788
1076
  text_timeline.append((line_text, timestamp, line.id))
789
-
1077
+
790
1078
  # Calculate statistics
791
1079
  duplicates_count = len(duplicates_to_remove)
792
- games_affected = len([game for game in game_lines.keys() if any(
793
- line.id in duplicates_to_remove for line in game_lines[game]
794
- )])
795
-
1080
+ games_affected = len(
1081
+ [
1082
+ game
1083
+ for game in game_lines.keys()
1084
+ if any(line.id in duplicates_to_remove for line in game_lines[game])
1085
+ ]
1086
+ )
1087
+
796
1088
  # Get sample duplicates
797
1089
  samples = list(duplicate_samples.values())[:10]
798
-
799
- return jsonify({
800
- 'duplicates_count': duplicates_count,
801
- 'games_affected': games_affected,
802
- 'samples': samples
803
- }), 200
804
-
1090
+
1091
+ return jsonify(
1092
+ {
1093
+ "duplicates_count": duplicates_count,
1094
+ "games_affected": games_affected,
1095
+ "samples": samples,
1096
+ }
1097
+ ), 200
1098
+
805
1099
  except Exception as e:
806
1100
  logger.error(f"Error in preview deduplication: {e}")
807
- return jsonify({'error': f'Preview failed: {str(e)}'}), 500
1101
+ return jsonify({"error": f"Preview failed: {str(e)}"}), 500
808
1102
 
809
- @app.route('/api/deduplicate', methods=['POST'])
1103
+ @app.route("/api/deduplicate", methods=["POST"])
810
1104
  def api_deduplicate():
811
1105
  """
812
1106
  Remove duplicate sentences from database based on time window and game selection.
@@ -815,42 +1109,42 @@ def register_database_api_routes(app):
815
1109
  try:
816
1110
  data = request.get_json()
817
1111
  if not data:
818
- return jsonify({'error': 'No data provided'}), 400
819
-
820
- games = data.get('games', [])
821
- time_window_minutes = data.get('time_window_minutes', 5)
822
- case_sensitive = data.get('case_sensitive', False)
823
- preserve_newest = data.get('preserve_newest', False)
824
- ignore_time_window = data.get('ignore_time_window', False)
825
-
1112
+ return jsonify({"error": "No data provided"}), 400
1113
+
1114
+ games = data.get("games", [])
1115
+ time_window_minutes = data.get("time_window_minutes", 5)
1116
+ case_sensitive = data.get("case_sensitive", False)
1117
+ preserve_newest = data.get("preserve_newest", False)
1118
+ ignore_time_window = data.get("ignore_time_window", False)
1119
+
826
1120
  if not games:
827
- return jsonify({'error': 'At least one game must be selected'}), 400
828
-
1121
+ return jsonify({"error": "At least one game must be selected"}), 400
1122
+
829
1123
  # Get lines from selected games
830
- if 'all' in games:
1124
+ if "all" in games:
831
1125
  all_lines = GameLinesTable.all()
832
1126
  else:
833
1127
  all_lines = []
834
1128
  for game_name in games:
835
1129
  game_lines = GameLinesTable.get_all_lines_for_scene(game_name)
836
1130
  all_lines.extend(game_lines)
837
-
1131
+
838
1132
  if not all_lines:
839
- return jsonify({'deleted_count': 0}), 200
840
-
1133
+ return jsonify({"deleted_count": 0}), 200
1134
+
841
1135
  # Group lines by game and sort by timestamp
842
1136
  game_lines = defaultdict(list)
843
1137
  for line in all_lines:
844
1138
  game_name = line.game_name or "Unknown Game"
845
1139
  game_lines[game_name].append(line)
846
-
1140
+
847
1141
  # Sort lines within each game by timestamp
848
1142
  for game_name in game_lines:
849
1143
  game_lines[game_name].sort(key=lambda x: float(x.timestamp))
850
-
1144
+
851
1145
  duplicates_to_remove = []
852
1146
  time_window_seconds = time_window_minutes * 60
853
-
1147
+
854
1148
  # Find duplicates for each game
855
1149
  for game_name, lines in game_lines.items():
856
1150
  if ignore_time_window:
@@ -859,9 +1153,11 @@ def register_database_api_routes(app):
859
1153
  for line in lines:
860
1154
  if not line.line_text or not line.line_text.strip():
861
1155
  continue
862
-
863
- line_text = line.line_text if case_sensitive else line.line_text.lower()
864
-
1156
+
1157
+ line_text = (
1158
+ line.line_text if case_sensitive else line.line_text.lower()
1159
+ )
1160
+
865
1161
  if line_text in seen_texts:
866
1162
  # Found duplicate
867
1163
  if preserve_newest:
@@ -876,20 +1172,24 @@ def register_database_api_routes(app):
876
1172
  else:
877
1173
  # Find duplicates within time window (original logic)
878
1174
  text_timeline = []
879
-
1175
+
880
1176
  for line in lines:
881
1177
  if not line.line_text or not line.line_text.strip():
882
1178
  continue
883
-
884
- line_text = line.line_text if case_sensitive else line.line_text.lower()
1179
+
1180
+ line_text = (
1181
+ line.line_text if case_sensitive else line.line_text.lower()
1182
+ )
885
1183
  timestamp = float(line.timestamp)
886
-
1184
+
887
1185
  # Check for duplicates within time window
888
1186
  duplicate_found = False
889
- for i, (prev_text, prev_timestamp, prev_line_id) in enumerate(reversed(text_timeline)):
1187
+ for i, (prev_text, prev_timestamp, prev_line_id) in enumerate(
1188
+ reversed(text_timeline)
1189
+ ):
890
1190
  if timestamp - prev_timestamp > time_window_seconds:
891
1191
  break # Outside time window
892
-
1192
+
893
1193
  if prev_text == line_text:
894
1194
  # Found duplicate within time window
895
1195
  if preserve_newest:
@@ -897,43 +1197,66 @@ def register_database_api_routes(app):
897
1197
  duplicates_to_remove.append(prev_line_id)
898
1198
  # Update timeline to replace old entry with new one
899
1199
  timeline_index = len(text_timeline) - 1 - i
900
- text_timeline[timeline_index] = (line_text, timestamp, line.id)
1200
+ text_timeline[timeline_index] = (
1201
+ line_text,
1202
+ timestamp,
1203
+ line.id,
1204
+ )
901
1205
  else:
902
1206
  # Remove the newer one (current)
903
1207
  duplicates_to_remove.append(line.id)
904
-
1208
+
905
1209
  duplicate_found = True
906
1210
  break
907
-
1211
+
908
1212
  if not duplicate_found:
909
1213
  text_timeline.append((line_text, timestamp, line.id))
910
-
1214
+
911
1215
  # Delete the duplicate lines
912
1216
  deleted_count = 0
913
- for line_id in set(duplicates_to_remove): # Remove duplicates from deletion list
1217
+ for line_id in set(
1218
+ duplicates_to_remove
1219
+ ): # Remove duplicates from deletion list
914
1220
  try:
915
1221
  GameLinesTable._db.execute(
916
1222
  f"DELETE FROM {GameLinesTable._table} WHERE id=?",
917
1223
  (line_id,),
918
- commit=True
1224
+ commit=True,
919
1225
  )
920
1226
  deleted_count += 1
921
1227
  except Exception as e:
922
1228
  logger.warning(f"Failed to delete duplicate line {line_id}: {e}")
923
-
924
- mode_desc = "entire game" if ignore_time_window else f"{time_window_minutes}min window"
925
- logger.info(f"Deduplication completed: removed {deleted_count} duplicate sentences from {len(games)} games with {mode_desc}")
926
-
927
- return jsonify({
928
- 'deleted_count': deleted_count,
929
- 'message': f'Successfully removed {deleted_count} duplicate sentences'
930
- }), 200
931
-
1229
+
1230
+ mode_desc = (
1231
+ "entire game"
1232
+ if ignore_time_window
1233
+ else f"{time_window_minutes}min window"
1234
+ )
1235
+ logger.info(
1236
+ f"Deduplication completed: removed {deleted_count} duplicate sentences from {len(games)} games with {mode_desc}"
1237
+ )
1238
+
1239
+ # Trigger stats rollup after successful deduplication
1240
+ if deleted_count > 0:
1241
+ try:
1242
+ logger.info("Triggering stats rollup after deduplication")
1243
+ run_daily_rollup()
1244
+ except Exception as rollup_error:
1245
+ logger.error(f"Stats rollup failed after deduplication: {rollup_error}")
1246
+ # Don't fail the deduplication operation if rollup fails
1247
+
1248
+ return jsonify(
1249
+ {
1250
+ "deleted_count": deleted_count,
1251
+ "message": f"Successfully removed {deleted_count} duplicate sentences",
1252
+ }
1253
+ ), 200
1254
+
932
1255
  except Exception as e:
933
1256
  logger.error(f"Error in deduplication: {e}")
934
- return jsonify({'error': f'Deduplication failed: {str(e)}'}), 500
1257
+ return jsonify({"error": f"Deduplication failed: {str(e)}"}), 500
935
1258
 
936
- @app.route('/api/deduplicate-entire-game', methods=['POST'])
1259
+ @app.route("/api/deduplicate-entire-game", methods=["POST"])
937
1260
  def api_deduplicate_entire_game():
938
1261
  """
939
1262
  Remove duplicate sentences from database across entire games without time window restrictions.
@@ -942,19 +1265,152 @@ def register_database_api_routes(app):
942
1265
  try:
943
1266
  data = request.get_json()
944
1267
  if not data:
945
- return jsonify({'error': 'No data provided'}), 400
946
-
1268
+ return jsonify({"error": "No data provided"}), 400
1269
+
947
1270
  # Add ignore_time_window=True to the request data
948
- data['ignore_time_window'] = True
949
-
1271
+ data["ignore_time_window"] = True
1272
+
950
1273
  # Call the main deduplication function
951
1274
  return api_deduplicate()
952
-
1275
+
953
1276
  except Exception as e:
954
1277
  logger.error(f"Error in entire game deduplication: {e}")
955
- return jsonify({'error': f'Entire game deduplication failed: {str(e)}'}), 500
1278
+ return jsonify(
1279
+ {"error": f"Entire game deduplication failed: {str(e)}"}
1280
+ ), 500
1281
+
1282
+ @app.route("/api/search-duplicates", methods=["POST"])
1283
+ def api_search_duplicates():
1284
+ """
1285
+ Search for duplicate sentences and return full line details for display in search results.
1286
+ Similar to preview-deduplication but returns complete line information with IDs.
1287
+ """
1288
+ try:
1289
+ data = request.get_json()
1290
+ if not data:
1291
+ return jsonify({"error": "No data provided"}), 400
1292
+
1293
+ game_filter = data.get("game", "")
1294
+ time_window_minutes = data.get("time_window_minutes", 5)
1295
+ case_sensitive = data.get("case_sensitive", False)
1296
+ ignore_time_window = data.get("ignore_time_window", False)
1297
+
1298
+ # Get lines from selected game or all games
1299
+ if game_filter:
1300
+ all_lines = GameLinesTable.get_all_lines_for_scene(game_filter)
1301
+ else:
1302
+ all_lines = GameLinesTable.all()
1303
+
1304
+ if not all_lines:
1305
+ return jsonify({
1306
+ "results": [],
1307
+ "total": 0,
1308
+ "duplicates_found": 0
1309
+ }), 200
1310
+
1311
+ # Group lines by game and sort by timestamp
1312
+ game_lines = defaultdict(list)
1313
+ for line in all_lines:
1314
+ game_name = line.game_name or "Unknown Game"
1315
+ game_lines[game_name].append(line)
1316
+
1317
+ # Sort lines within each game by timestamp
1318
+ for game_name in game_lines:
1319
+ game_lines[game_name].sort(key=lambda x: float(x.timestamp))
1320
+
1321
+ duplicate_line_ids = set()
1322
+ time_window_seconds = time_window_minutes * 60
1323
+
1324
+ # Find duplicates for each game
1325
+ for game_name, lines in game_lines.items():
1326
+ if ignore_time_window:
1327
+ # Find all duplicates regardless of time
1328
+ seen_texts = {}
1329
+ for line in lines:
1330
+ # Ensure line_text is a string
1331
+ if not line.line_text or not isinstance(line.line_text, str):
1332
+ continue
1333
+ if not line.line_text.strip():
1334
+ continue
1335
+
1336
+ line_text = (
1337
+ line.line_text if case_sensitive else line.line_text.lower()
1338
+ )
1339
+
1340
+ if line_text in seen_texts:
1341
+ # Mark this as a duplicate (keep first occurrence)
1342
+ duplicate_line_ids.add(line.id)
1343
+ else:
1344
+ seen_texts[line_text] = line.id
1345
+ else:
1346
+ # Find duplicates within time window
1347
+ text_timeline = []
1348
+
1349
+ for line in lines:
1350
+ # Ensure line_text is a string
1351
+ if not line.line_text or not isinstance(line.line_text, str):
1352
+ continue
1353
+ if not line.line_text.strip():
1354
+ continue
1355
+
1356
+ line_text = (
1357
+ line.line_text if case_sensitive else line.line_text.lower()
1358
+ )
1359
+ timestamp = float(line.timestamp)
1360
+
1361
+ # Check for duplicates within time window
1362
+ for prev_text, prev_timestamp, prev_line_id in reversed(
1363
+ text_timeline
1364
+ ):
1365
+ if timestamp - prev_timestamp > time_window_seconds:
1366
+ break # Outside time window
1367
+
1368
+ if prev_text == line_text:
1369
+ # Found duplicate within time window
1370
+ duplicate_line_ids.add(line.id)
1371
+ break
1372
+
1373
+ text_timeline.append((line_text, timestamp, line.id))
1374
+
1375
+ # Get full details for all duplicate lines
1376
+ duplicate_lines = [line for line in all_lines if line.id in duplicate_line_ids]
1377
+
1378
+ # Group duplicates by normalized text for sorting
1379
+ # Sort by: 1) normalized text (to group duplicates), 2) timestamp (oldest first within group)
1380
+ def get_sort_key(line):
1381
+ if not line.line_text or not isinstance(line.line_text, str):
1382
+ return ("", 0)
1383
+ normalized_text = line.line_text.lower() if not case_sensitive else line.line_text
1384
+ timestamp = float(line.timestamp) if line.timestamp else 0
1385
+ return (normalized_text, timestamp)
1386
+
1387
+ duplicate_lines.sort(key=get_sort_key)
1388
+
1389
+ # Format results to match search results format
1390
+ results = []
1391
+ for line in duplicate_lines:
1392
+ results.append({
1393
+ "id": line.id,
1394
+ "sentence": line.line_text or "",
1395
+ "game_name": line.game_name or "Unknown Game",
1396
+ "timestamp": float(line.timestamp) if line.timestamp else 0,
1397
+ "translation": line.translation or None,
1398
+ "has_audio": bool(getattr(line, "audio_path", None)),
1399
+ "has_screenshot": bool(getattr(line, "screenshot_path", None)),
1400
+ })
956
1401
 
957
- @app.route('/api/merge_games', methods=['POST'])
1402
+ return jsonify({
1403
+ "results": results,
1404
+ "total": len(results),
1405
+ "duplicates_found": len(results),
1406
+ "search_mode": "duplicates"
1407
+ }), 200
1408
+
1409
+ except Exception as e:
1410
+ logger.error(f"Error in search duplicates: {e}")
1411
+ return jsonify({"error": f"Duplicate search failed: {str(e)}"}), 500
1412
+
1413
+ @app.route("/api/merge_games", methods=["POST"])
958
1414
  def api_merge_games():
959
1415
  """
960
1416
  Merges multiple selected games into a single game entry.
@@ -963,47 +1419,57 @@ def register_database_api_routes(app):
963
1419
  """
964
1420
  try:
965
1421
  data = request.get_json()
966
- target_game = data.get('target_game', None)
967
- games_to_merge = data.get('games_to_merge', [])
968
-
969
- logger.info(f"Merge request received: target_game='{target_game}', games_to_merge={games_to_merge}")
970
-
1422
+ target_game = data.get("target_game", None)
1423
+ games_to_merge = data.get("games_to_merge", [])
1424
+
1425
+ logger.info(
1426
+ f"Merge request received: target_game='{target_game}', games_to_merge={games_to_merge}"
1427
+ )
1428
+
971
1429
  # Validation
972
1430
  if not target_game:
973
- return jsonify({'error': 'No target game specified for merging'}), 400
1431
+ return jsonify({"error": "No target game specified for merging"}), 400
974
1432
 
975
1433
  if not games_to_merge:
976
- return jsonify({'error': 'No games specified for merging'}), 400
977
-
1434
+ return jsonify({"error": "No games specified for merging"}), 400
1435
+
978
1436
  if not isinstance(games_to_merge, list):
979
- return jsonify({'error': 'game_names must be a list'}), 400
980
-
1437
+ return jsonify({"error": "game_names must be a list"}), 400
1438
+
981
1439
  if len(games_to_merge) < 1:
982
- return jsonify({'error': 'At least 1 game must be selected for merging'}), 400
1440
+ return jsonify(
1441
+ {"error": "At least 1 game must be selected for merging"}
1442
+ ), 400
983
1443
 
984
1444
  # Validate that all games exist
985
1445
  existing_games = GameLinesTable.get_all_games_with_lines()
986
- invalid_games = [name for name in games_to_merge if name not in existing_games]
987
-
1446
+ invalid_games = [
1447
+ name for name in games_to_merge if name not in existing_games
1448
+ ]
1449
+
988
1450
  if invalid_games:
989
- return jsonify({'error': f'Games not found: {", ".join(invalid_games)}'}), 400
990
-
1451
+ return jsonify(
1452
+ {"error": f"Games not found: {', '.join(invalid_games)}"}
1453
+ ), 400
1454
+
991
1455
  # Check for duplicate game names
992
1456
  if len(set(games_to_merge)) != len(games_to_merge):
993
- return jsonify({'error': 'Duplicate game names found in selection'}), 400
994
-
1457
+ return jsonify(
1458
+ {"error": "Duplicate game names found in selection"}
1459
+ ), 400
1460
+
995
1461
  # Identify primary and secondary games
996
1462
 
997
1463
  # Collect pre-merge statistics
998
1464
  primary_lines_before = GameLinesTable.get_all_lines_for_scene(target_game)
999
1465
  total_lines_to_merge = 0
1000
1466
  merge_summary = {
1001
- 'primary_game': target_game,
1002
- 'secondary_games': games_to_merge,
1003
- 'lines_moved': 0,
1004
- 'total_lines_after_merge': 0
1467
+ "primary_game": target_game,
1468
+ "secondary_games": games_to_merge,
1469
+ "lines_moved": 0,
1470
+ "total_lines_after_merge": 0,
1005
1471
  }
1006
-
1472
+
1007
1473
  # Calculate lines to be moved and store counts
1008
1474
  secondary_game_line_counts = {}
1009
1475
  for game_name in games_to_merge:
@@ -1011,10 +1477,12 @@ def register_database_api_routes(app):
1011
1477
  line_count = len(secondary_lines)
1012
1478
  secondary_game_line_counts[game_name] = line_count
1013
1479
  total_lines_to_merge += line_count
1014
-
1480
+
1015
1481
  if total_lines_to_merge == 0:
1016
- return jsonify({'error': 'No lines found in secondary games to merge'}), 400
1017
-
1482
+ return jsonify(
1483
+ {"error": "No lines found in secondary games to merge"}
1484
+ ), 400
1485
+
1018
1486
  # Begin database transaction for merge
1019
1487
  try:
1020
1488
  # Perform the merge operation within transaction
@@ -1024,831 +1492,59 @@ def register_database_api_routes(app):
1024
1492
  # Also set original_game_name to preserve the original title
1025
1493
  # Ensure the table name is as expected to prevent SQL injection
1026
1494
  if GameLinesTable._table != "game_lines":
1027
- raise ValueError("Unexpected table name in GameLinesTable._table")
1495
+ raise ValueError(
1496
+ "Unexpected table name in GameLinesTable._table"
1497
+ )
1028
1498
  GameLinesTable._db.execute(
1029
1499
  "UPDATE game_lines SET game_name=?, original_game_name=COALESCE(original_game_name, ?) WHERE game_name=?",
1030
1500
  (target_game, game_name, game_name),
1031
- commit=True
1501
+ commit=True,
1032
1502
  )
1033
-
1503
+
1034
1504
  # Add the count we calculated earlier
1035
1505
  lines_moved += secondary_game_line_counts[game_name]
1036
-
1506
+
1037
1507
  # Update merge summary
1038
- merge_summary['lines_moved'] = lines_moved
1039
- merge_summary['total_lines_after_merge'] = len(primary_lines_before) + lines_moved
1040
-
1508
+ merge_summary["lines_moved"] = lines_moved
1509
+ merge_summary["total_lines_after_merge"] = (
1510
+ len(primary_lines_before) + lines_moved
1511
+ )
1512
+
1041
1513
  # Log the successful merge
1042
- logger.info(f"Successfully merged {len(games_to_merge)} games into '{target_game}': moved {lines_moved} lines")
1514
+ logger.info(
1515
+ f"Successfully merged {len(games_to_merge)} games into '{target_game}': moved {lines_moved} lines"
1516
+ )
1043
1517
 
1044
1518
  # Prepare success response
1045
1519
  response_data = {
1046
- 'message': f'Successfully merged {len(games_to_merge)} games into "{target_game}"',
1047
- 'primary_game': target_game,
1048
- 'merged_games': games_to_merge,
1049
- 'lines_moved': lines_moved,
1050
- 'total_lines_in_primary': merge_summary['total_lines_after_merge'],
1051
- 'merge_summary': merge_summary
1520
+ "message": f'Successfully merged {len(games_to_merge)} games into "{target_game}"',
1521
+ "primary_game": target_game,
1522
+ "merged_games": games_to_merge,
1523
+ "lines_moved": lines_moved,
1524
+ "total_lines_in_primary": merge_summary["total_lines_after_merge"],
1525
+ "merge_summary": merge_summary,
1052
1526
  }
1053
-
1054
- return jsonify(response_data), 200
1055
-
1056
- except Exception as db_error:
1057
- logger.error(f"Database error during game merge: {db_error}", exc_info=True)
1058
- return jsonify({
1059
- 'error': f'Failed to merge games due to database error: {str(db_error)}'
1060
- }), 500
1061
-
1062
- except Exception as e:
1063
- logger.error(f"Error in game merge API: {e}")
1064
- return jsonify({'error': f'Game merge failed: {str(e)}'}), 500
1065
-
1066
- @app.route('/api/stats')
1067
- def api_stats():
1068
- """
1069
- Provides aggregated, cumulative stats for charting.
1070
- Accepts optional 'year' parameter to filter heatmap data.
1071
- """
1072
- try:
1073
- # Get optional year filter parameter
1074
- filter_year = request.args.get('year', None)
1075
-
1076
- # Get Start and End time as unix timestamp
1077
- start_timestamp = request.args.get('start', None)
1078
- end_timestamp = request.args.get('end', None)
1079
-
1080
- # Convert timestamps to float if provided
1081
- start_timestamp = float(start_timestamp) if start_timestamp else None
1082
- end_timestamp = float(end_timestamp) if end_timestamp else None
1083
-
1084
- # 1. Fetch all lines and sort them chronologically
1085
- all_lines = GameLinesTable.get_lines_filtered_by_timestamp(start=start_timestamp, end=end_timestamp, for_stats=True)
1086
-
1087
- if not all_lines:
1088
- return jsonify({"labels": [], "datasets": []})
1089
-
1090
- # 2. Process data into daily totals for each game
1091
- # Structure: daily_data[date_str][game_name] = {'lines': N, 'chars': N}
1092
- daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
1093
- wrong_instance_found = False
1094
- for line in all_lines:
1095
- day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
1096
- game = line.game_name or "Unknown Game"
1097
- # Remove punctuation and symbols from line text before counting characters
1098
- if not isinstance(line.line_text, str) and not wrong_instance_found:
1099
- logger.info(f"Non-string line_text encountered: {line.line_text} (type: {type(line.line_text)})")
1100
- wrong_instance_found = True
1101
-
1102
- daily_data[day_str][game]['lines'] += 1
1103
- daily_data[day_str][game]['chars'] += len(line.line_text)
1104
-
1105
- # 3. Create cumulative datasets for Chart.js
1106
- sorted_days = sorted(daily_data.keys())
1107
- game_names = GameLinesTable.get_all_games_with_lines()
1108
-
1109
- # Keep track of the running total for each metric for each game
1110
- cumulative_totals = defaultdict(lambda: {'lines': 0, 'chars': 0})
1111
-
1112
- # Structure for final data: final_data[game_name][metric] = [day1_val, day2_val, ...]
1113
- final_data = defaultdict(lambda: defaultdict(list))
1114
-
1115
- for day in sorted_days:
1116
- for game in game_names:
1117
- # Add the day's total to the cumulative total
1118
- cumulative_totals[game]['lines'] += daily_data[day][game]['lines']
1119
- cumulative_totals[game]['chars'] += daily_data[day][game]['chars']
1120
-
1121
- # Append the new cumulative total to the list for that day
1122
- final_data[game]['lines'].append(cumulative_totals[game]['lines'])
1123
- final_data[game]['chars'].append(cumulative_totals[game]['chars'])
1124
-
1125
- # 4. Format into Chart.js dataset structure
1126
- datasets = []
1127
- # A simple color palette for the chart lines
1128
- colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
1129
-
1130
- for i, game in enumerate(game_names):
1131
- color = colors[i % len(colors)]
1132
-
1133
- # Note: We already have filtered data in all_lines from line 965, so we don't need to fetch again
1134
- # The duplicate data fetching that was here has been removed to fix the date range filtering issue
1135
-
1136
- # 4. Format into Chart.js dataset structure
1137
- try:
1138
- datasets = []
1139
- # A simple color palette for the chart lines
1140
- colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
1141
-
1142
- for i, game in enumerate(game_names):
1143
- color = colors[i % len(colors)]
1144
-
1145
- datasets.append({
1146
- "label": f"{game}",
1147
- "data": final_data[game]['lines'],
1148
- "borderColor": color,
1149
- "backgroundColor": f"{color}33", # Semi-transparent for fill
1150
- "fill": False,
1151
- "tension": 0.1,
1152
- "for": "Lines Received"
1153
- })
1154
- datasets.append({
1155
- "label": f"{game}",
1156
- "data": final_data[game]['chars'],
1157
- "borderColor": color,
1158
- "backgroundColor": f"{color}33",
1159
- "fill": False,
1160
- "tension": 0.1,
1161
- "hidden": True, # Hide by default to not clutter the chart
1162
- "for": "Characters Read"
1163
- })
1164
- except Exception as e:
1165
- logger.error(f"Error formatting Chart.js datasets: {e}")
1166
- return jsonify({'error': 'Failed to format chart data'}), 500
1167
-
1168
- # 5. Calculate additional chart data
1169
- try:
1170
- kanji_grid_data = calculate_kanji_frequency(all_lines)
1171
- except Exception as e:
1172
- logger.error(f"Error calculating kanji frequency: {e}")
1173
- kanji_grid_data = []
1174
-
1175
- try:
1176
- heatmap_data = calculate_heatmap_data(all_lines, filter_year)
1177
- except Exception as e:
1178
- logger.error(f"Error calculating heatmap data: {e}")
1179
- heatmap_data = []
1180
-
1181
- try:
1182
- total_chars_data = calculate_total_chars_per_game(all_lines)
1183
- except Exception as e:
1184
- logger.error(f"Error calculating total chars per game: {e}")
1185
- total_chars_data = {}
1186
-
1187
- try:
1188
- reading_time_data = calculate_reading_time_per_game(all_lines)
1189
- except Exception as e:
1190
- logger.error(f"Error calculating reading time per game: {e}")
1191
- reading_time_data = {}
1192
-
1193
- try:
1194
- reading_speed_per_game_data = calculate_reading_speed_per_game(all_lines)
1195
- except Exception as e:
1196
- logger.error(f"Error calculating reading speed per game: {e}")
1197
- reading_speed_per_game_data = {}
1198
-
1199
- # 6. Calculate dashboard statistics
1200
- try:
1201
- current_game_stats = calculate_current_game_stats(all_lines)
1202
- except Exception as e:
1203
- logger.error(f"Error calculating current game stats: {e}")
1204
- current_game_stats = {}
1205
-
1206
- try:
1207
- all_games_stats = calculate_all_games_stats(all_lines)
1208
- except Exception as e:
1209
- logger.error(f"Error calculating all games stats: {e}")
1210
- all_games_stats = {}
1211
-
1212
- # 7. Prepare allLinesData for frontend calculations (needed for average daily time)
1213
- try:
1214
- all_lines_data = []
1215
- for line in all_lines:
1216
- all_lines_data.append({
1217
- 'timestamp': float(line.timestamp),
1218
- 'game_name': line.game_name or 'Unknown Game',
1219
- 'characters': len(line.line_text) if line.line_text else 0,
1220
- 'id': line.id
1221
- })
1222
- except Exception as e:
1223
- logger.error(f"Error preparing all lines data: {e}")
1224
- all_lines_data = []
1225
-
1226
- # 8. Calculate hourly activity pattern
1227
- try:
1228
- hourly_activity_data = calculate_hourly_activity(all_lines)
1229
- except Exception as e:
1230
- logger.error(f"Error calculating hourly activity: {e}")
1231
- hourly_activity_data = [0] * 24
1232
-
1233
- # 8.5. Calculate hourly reading speed pattern
1234
- try:
1235
- hourly_reading_speed_data = calculate_hourly_reading_speed(all_lines)
1236
- except Exception as e:
1237
- logger.error(f"Error calculating hourly reading speed: {e}")
1238
- hourly_reading_speed_data = [0] * 24
1239
-
1240
- # 9. Calculate peak statistics
1241
- try:
1242
- peak_daily_stats = calculate_peak_daily_stats(all_lines)
1243
- except Exception as e:
1244
- logger.error(f"Error calculating peak daily stats: {e}")
1245
- peak_daily_stats = {'max_daily_chars': 0, 'max_daily_hours': 0.0}
1246
-
1247
- try:
1248
- peak_session_stats = calculate_peak_session_stats(all_lines)
1249
- except Exception as e:
1250
- logger.error(f"Error calculating peak session stats: {e}")
1251
- peak_session_stats = {'longest_session_hours': 0.0, 'max_session_chars': 0}
1252
-
1253
- return jsonify({
1254
- "labels": sorted_days,
1255
- "datasets": datasets,
1256
- "kanjiGridData": kanji_grid_data,
1257
- "heatmapData": heatmap_data,
1258
- "totalCharsPerGame": total_chars_data,
1259
- "readingTimePerGame": reading_time_data,
1260
- "readingSpeedPerGame": reading_speed_per_game_data,
1261
- "currentGameStats": current_game_stats,
1262
- "allGamesStats": all_games_stats,
1263
- "allLinesData": all_lines_data,
1264
- "hourlyActivityData": hourly_activity_data,
1265
- "hourlyReadingSpeedData": hourly_reading_speed_data,
1266
- "peakDailyStats": peak_daily_stats,
1267
- "peakSessionStats": peak_session_stats
1268
- })
1269
-
1270
- except Exception as e:
1271
- logger.error(f"Unexpected error in api_stats: {e}", exc_info=True)
1272
- return jsonify({'error': 'Failed to generate statistics'}), 500
1273
-
1274
- @app.route('/api/mining_heatmap')
1275
- def api_mining_heatmap():
1276
- """
1277
- Provides mining heatmap data showing daily mining activity.
1278
- Counts lines where screenshot_in_anki OR audio_in_anki is not empty.
1279
- Accepts optional 'start' and 'end' timestamp parameters for filtering.
1280
- """
1281
- try:
1282
- # Get optional timestamp filter parameters
1283
- start_timestamp = request.args.get('start', None)
1284
- end_timestamp = request.args.get('end', None)
1285
-
1286
- # Convert timestamps to float if provided
1287
- start_timestamp = float(start_timestamp) if start_timestamp else None
1288
- end_timestamp = float(end_timestamp) if end_timestamp else None
1289
-
1290
- # Fetch lines filtered by timestamp
1291
- all_lines = GameLinesTable.get_lines_filtered_by_timestamp(start=start_timestamp, end=end_timestamp)
1292
-
1293
- if not all_lines:
1294
- return jsonify({}), 200
1295
-
1296
- # Calculate mining heatmap data
1297
- try:
1298
- heatmap_data = calculate_mining_heatmap_data(all_lines)
1299
- except Exception as e:
1300
- logger.error(f"Error calculating mining heatmap data: {e}")
1301
- return jsonify({'error': 'Failed to calculate mining heatmap'}), 500
1302
-
1303
- return jsonify(heatmap_data), 200
1304
-
1305
- except Exception as e:
1306
- logger.error(f"Unexpected error in api_mining_heatmap: {e}", exc_info=True)
1307
- return jsonify({'error': 'Failed to generate mining heatmap'}), 500
1308
-
1309
- @app.route('/api/goals-today', methods=['GET'])
1310
- def api_goals_today():
1311
- """
1312
- Calculate daily requirements and current progress for today based on goal target dates.
1313
- Returns what needs to be accomplished today to stay on track.
1314
- """
1315
- try:
1316
- config = get_stats_config()
1317
- today = datetime.date.today()
1318
-
1319
- # Get all lines for overall progress
1320
- all_lines = GameLinesTable.all(for_stats=True)
1321
- if not all_lines:
1322
- return jsonify({
1323
- 'hours': {'required': 0, 'progress': 0, 'has_target': False},
1324
- 'characters': {'required': 0, 'progress': 0, 'has_target': False},
1325
- 'games': {'required': 0, 'progress': 0, 'has_target': False}
1326
- }), 200
1327
-
1328
- # Calculate overall current progress
1329
- timestamps = [float(line.timestamp) for line in all_lines]
1330
- total_time_seconds = calculate_actual_reading_time(timestamps)
1331
- total_hours = total_time_seconds / 3600
1332
- total_characters = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
1333
- total_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
1334
-
1335
- # Get today's lines for progress
1336
- today_lines = [line for line in all_lines
1337
- if datetime.date.fromtimestamp(float(line.timestamp)) == today]
1338
-
1339
- today_timestamps = [float(line.timestamp) for line in today_lines]
1340
- today_time_seconds = calculate_actual_reading_time(today_timestamps) if len(today_timestamps) >= 2 else 0
1341
- today_hours = today_time_seconds / 3600
1342
- today_characters = sum(len(line.line_text) if line.line_text else 0 for line in today_lines)
1343
-
1344
- result = {}
1345
-
1346
- # Calculate hours requirement
1347
- if config.reading_hours_target_date:
1348
- try:
1349
- target_date = datetime.datetime.strptime(config.reading_hours_target_date, '%Y-%m-%d').date()
1350
- days_remaining = (target_date - today).days + 1 # +1 to include today
1351
- if days_remaining > 0:
1352
- hours_needed = max(0, config.reading_hours_target - total_hours)
1353
- daily_hours_required = hours_needed / days_remaining
1354
- result['hours'] = {
1355
- 'required': round(daily_hours_required, 2),
1356
- 'progress': round(today_hours, 2),
1357
- 'has_target': True,
1358
- 'target_date': config.reading_hours_target_date,
1359
- 'days_remaining': days_remaining
1360
- }
1361
- else:
1362
- result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': True, 'expired': True}
1363
- except ValueError:
1364
- result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': False}
1365
- else:
1366
- result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': False}
1367
-
1368
- # Calculate characters requirement
1369
- if config.character_count_target_date:
1370
- try:
1371
- target_date = datetime.datetime.strptime(config.character_count_target_date, '%Y-%m-%d').date()
1372
- days_remaining = (target_date - today).days + 1
1373
- if days_remaining > 0:
1374
- chars_needed = max(0, config.character_count_target - total_characters)
1375
- daily_chars_required = int(chars_needed / days_remaining)
1376
- result['characters'] = {
1377
- 'required': daily_chars_required,
1378
- 'progress': today_characters,
1379
- 'has_target': True,
1380
- 'target_date': config.character_count_target_date,
1381
- 'days_remaining': days_remaining
1382
- }
1383
- else:
1384
- result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': True, 'expired': True}
1385
- except ValueError:
1386
- result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': False}
1387
- else:
1388
- result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': False}
1389
-
1390
- # Calculate games requirement
1391
- if config.games_target_date:
1392
- try:
1393
- target_date = datetime.datetime.strptime(config.games_target_date, '%Y-%m-%d').date()
1394
- days_remaining = (target_date - today).days + 1
1395
- if days_remaining > 0:
1396
- games_needed = max(0, config.games_target - total_games)
1397
- daily_games_required = games_needed / days_remaining
1398
- result['games'] = {
1399
- 'required': round(daily_games_required, 2),
1400
- 'progress': total_games,
1401
- 'has_target': True,
1402
- 'target_date': config.games_target_date,
1403
- 'days_remaining': days_remaining
1404
- }
1405
- else:
1406
- result['games'] = {'required': 0, 'progress': total_games, 'has_target': True, 'expired': True}
1407
- except ValueError:
1408
- result['games'] = {'required': 0, 'progress': total_games, 'has_target': False}
1409
- else:
1410
- result['games'] = {'required': 0, 'progress': total_games, 'has_target': False}
1411
-
1412
- return jsonify(result), 200
1413
-
1414
- except Exception as e:
1415
- logger.error(f"Error calculating goals today: {e}")
1416
- return jsonify({'error': 'Failed to calculate daily goals'}), 500
1417
-
1418
- @app.route('/api/goals-projection', methods=['GET'])
1419
- def api_goals_projection():
1420
- """
1421
- Calculate projections based on 30-day rolling average.
1422
- Returns projected stats by target dates.
1423
- """
1424
- try:
1425
- config = get_stats_config()
1426
- today = datetime.date.today()
1427
- thirty_days_ago = today - datetime.timedelta(days=30)
1428
-
1429
- # Get all lines
1430
- all_lines = GameLinesTable.all(for_stats=True)
1431
- if not all_lines:
1432
- return jsonify({
1433
- 'hours': {'projection': 0, 'daily_average': 0},
1434
- 'characters': {'projection': 0, 'daily_average': 0},
1435
- 'games': {'projection': 0, 'daily_average': 0}
1436
- }), 200
1437
-
1438
- # Get last 30 days of lines
1439
- recent_lines = [line for line in all_lines
1440
- if datetime.date.fromtimestamp(float(line.timestamp)) >= thirty_days_ago]
1441
-
1442
- # Calculate 30-day averages
1443
- if recent_lines:
1444
- # Group by day for accurate averaging
1445
- daily_data = defaultdict(lambda: {'timestamps': [], 'characters': 0, 'games': set()})
1446
- for line in recent_lines:
1447
- day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
1448
- daily_data[day_str]['timestamps'].append(float(line.timestamp))
1449
- daily_data[day_str]['characters'] += len(line.line_text) if line.line_text else 0
1450
- daily_data[day_str]['games'].add(line.game_name or "Unknown Game")
1451
-
1452
- # Calculate daily averages
1453
- total_hours = 0
1454
- total_chars = 0
1455
- total_unique_games = set()
1456
-
1457
- for day_data in daily_data.values():
1458
- if len(day_data['timestamps']) >= 2:
1459
- day_seconds = calculate_actual_reading_time(day_data['timestamps'])
1460
- total_hours += day_seconds / 3600
1461
- total_chars += day_data['characters']
1462
- total_unique_games.update(day_data['games'])
1463
-
1464
- # Average over ALL 30 days (including days with 0 activity)
1465
- avg_daily_hours = total_hours / 30
1466
- avg_daily_chars = total_chars / 30
1467
- # Calculate average daily unique games correctly
1468
- today = datetime.date.today()
1469
- daily_unique_games_counts = []
1470
- for i in range(30):
1471
- day = (today - datetime.timedelta(days=i)).strftime('%Y-%m-%d')
1472
- daily_unique_games_counts.append(len(daily_data[day]['games']) if day in daily_data else 0)
1473
- avg_daily_games = sum(daily_unique_games_counts) / 30
1474
- else:
1475
- avg_daily_hours = 0
1476
- avg_daily_chars = 0
1477
- avg_daily_games = 0
1478
-
1479
- # Calculate current totals
1480
- timestamps = [float(line.timestamp) for line in all_lines]
1481
- current_hours = calculate_actual_reading_time(timestamps) / 3600
1482
- current_chars = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
1483
- current_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
1484
-
1485
- result = {}
1486
-
1487
- # Project hours by target date
1488
- if config.reading_hours_target_date:
1489
- try:
1490
- target_date = datetime.datetime.strptime(config.reading_hours_target_date, '%Y-%m-%d').date()
1491
- days_until_target = (target_date - today).days
1492
- projected_hours = current_hours + (avg_daily_hours * days_until_target)
1493
- result['hours'] = {
1494
- 'projection': round(projected_hours, 2),
1495
- 'daily_average': round(avg_daily_hours, 2),
1496
- 'target_date': config.reading_hours_target_date,
1497
- 'target': config.reading_hours_target,
1498
- 'current': round(current_hours, 2)
1499
- }
1500
- except ValueError:
1501
- result['hours'] = {'projection': 0, 'daily_average': round(avg_daily_hours, 2)}
1502
- else:
1503
- result['hours'] = {'projection': 0, 'daily_average': round(avg_daily_hours, 2)}
1504
-
1505
- # Project characters by target date
1506
- if config.character_count_target_date:
1507
- try:
1508
- target_date = datetime.datetime.strptime(config.character_count_target_date, '%Y-%m-%d').date()
1509
- days_until_target = (target_date - today).days
1510
- projected_chars = int(current_chars + (avg_daily_chars * days_until_target))
1511
- result['characters'] = {
1512
- 'projection': projected_chars,
1513
- 'daily_average': int(avg_daily_chars),
1514
- 'target_date': config.character_count_target_date,
1515
- 'target': config.character_count_target,
1516
- 'current': current_chars
1517
- }
1518
- except ValueError:
1519
- result['characters'] = {'projection': 0, 'daily_average': int(avg_daily_chars)}
1520
- else:
1521
- result['characters'] = {'projection': 0, 'daily_average': int(avg_daily_chars)}
1522
-
1523
- # Project games by target date
1524
- if config.games_target_date:
1525
- try:
1526
- target_date = datetime.datetime.strptime(config.games_target_date, '%Y-%m-%d').date()
1527
- days_until_target = (target_date - today).days
1528
- projected_games = int(current_games + (avg_daily_games * days_until_target))
1529
- result['games'] = {
1530
- 'projection': projected_games,
1531
- 'daily_average': round(avg_daily_games, 2),
1532
- 'target_date': config.games_target_date,
1533
- 'target': config.games_target,
1534
- 'current': current_games
1535
- }
1536
- except ValueError:
1537
- result['games'] = {'projection': 0, 'daily_average': round(avg_daily_games, 2)}
1538
- else:
1539
- result['games'] = {'projection': 0, 'daily_average': round(avg_daily_games, 2)}
1540
-
1541
- return jsonify(result), 200
1542
-
1543
- except Exception as e:
1544
- logger.error(f"Error calculating goal projections: {e}")
1545
- return jsonify({'error': 'Failed to calculate projections'}), 500
1546
1527
 
1547
- @app.route('/api/import-exstatic', methods=['POST'])
1548
- def api_import_exstatic():
1549
- """
1550
- Import ExStatic CSV data into GSM database.
1551
- Expected CSV format: uuid,given_identifier,name,line,time
1552
- """
1553
- try:
1554
- # Check if file is provided
1555
- if 'file' not in request.files:
1556
- return jsonify({'error': 'No file provided'}), 400
1557
-
1558
- file = request.files['file']
1559
- if file.filename == '':
1560
- return jsonify({'error': 'No file selected'}), 400
1561
-
1562
- # Validate file type
1563
- if not file.filename.lower().endswith('.csv'):
1564
- return jsonify({'error': 'File must be a CSV file'}), 400
1565
-
1566
- # Read and parse CSV
1567
- try:
1568
- # Read file content as text with proper encoding handling
1569
- file_content = file.read().decode('utf-8-sig') # Handle BOM if present
1570
-
1571
- # First, get the header line manually to avoid issues with multi-line content
1572
- lines = file_content.split('\n')
1573
- if len(lines) == 1 and not lines[0].strip():
1574
- return jsonify({'error': 'Empty CSV file'}), 400
1575
-
1576
- header_line = lines[0].strip()
1577
- logger.info(f"Header line: {header_line}")
1578
-
1579
- # Parse headers manually
1580
- header_reader = csv.reader([header_line])
1528
+ # Trigger stats rollup after successful merge
1581
1529
  try:
1582
- headers = next(header_reader)
1583
- headers = [h.strip() for h in headers] # Clean whitespace
1584
- logger.info(f"Parsed headers: {headers}")
1585
- except StopIteration:
1586
- return jsonify({'error': 'Could not parse CSV headers'}), 400
1587
-
1588
- # Validate headers
1589
- expected_headers = {'uuid', 'given_identifier', 'name', 'line', 'time'}
1590
- actual_headers = set(headers)
1591
-
1592
- if not expected_headers.issubset(actual_headers):
1593
- missing_headers = expected_headers - actual_headers
1594
- # Check if this looks like a stats CSV instead of lines CSV
1595
- if 'client' in actual_headers and 'chars_read' in actual_headers:
1596
- return jsonify({
1597
- 'error': 'This appears to be an ExStatic stats CSV. Please upload the ExStatic lines CSV file instead. The lines CSV should contain columns: uuid, given_identifier, name, line, time'
1598
- }), 400
1599
- else:
1600
- return jsonify({
1601
- 'error': f'Invalid CSV format. Missing required columns: {", ".join(missing_headers)}. Expected format: uuid, given_identifier, name, line, time. Found headers: {", ".join(actual_headers)}'
1602
- }), 400
1603
-
1604
- # Now parse the full CSV with proper handling for multi-line fields
1605
- file_io = io.StringIO(file_content)
1606
- csv_reader = csv.DictReader(file_io, quoting=csv.QUOTE_MINIMAL, skipinitialspace=True)
1607
-
1608
- # Process CSV rows
1609
- games_set = set()
1610
- errors = []
1611
-
1612
- all_lines = GameLinesTable.all()
1613
- existing_uuids = {line.id for line in all_lines}
1614
- batch_size = 1000 # For logging progress
1615
- batch_insert = []
1616
- imported_count = 0
1617
-
1618
- def get_line_hash(uuid: str, line_text: str) -> str:
1619
- return uuid + '|' + line_text.strip()
1620
-
1621
- for row_num, row in enumerate(csv_reader):
1622
- try:
1623
- # Extract and validate required fields
1624
- game_uuid = row.get('uuid', '').strip()
1625
- game_name = row.get('name', '').strip()
1626
- line = row.get('line', '').strip()
1627
- time_str = row.get('time', '').strip()
1628
-
1629
- # Validate required fields
1630
- if not game_uuid:
1631
- errors.append(f"Row {row_num}: Missing UUID")
1632
- continue
1633
- if not game_name:
1634
- errors.append(f"Row {row_num}: Missing name")
1635
- continue
1636
- if not line:
1637
- errors.append(f"Row {row_num}: Missing line text")
1638
- continue
1639
- if not time_str:
1640
- errors.append(f"Row {row_num}: Missing time")
1641
- continue
1642
-
1643
- line_hash = get_line_hash(game_uuid, line)
1644
-
1645
- # Check if this line already exists in database
1646
- if line_hash in existing_uuids:
1647
- logger.info(f"Skipping duplicate UUID already in database: {line_hash}")
1648
- continue
1530
+ logger.info("Triggering stats rollup after game merge")
1531
+ run_daily_rollup()
1532
+ except Exception as rollup_error:
1533
+ logger.error(f"Stats rollup failed after game merge: {rollup_error}")
1534
+ # Don't fail the merge operation if rollup fails
1649
1535
 
1650
- # Convert time to timestamp
1651
- try:
1652
- timestamp = float(time_str)
1653
- except ValueError:
1654
- errors.append(f"Row {row_num}: Invalid time format: {time_str}")
1655
- continue
1656
-
1657
- # Clean up line text (remove extra whitespace and newlines)
1658
- line_text = line.strip()
1659
-
1660
- # Create GameLinesTable entry
1661
- # Convert timestamp float to datetime object
1662
- dt = datetime.datetime.fromtimestamp(timestamp)
1663
- batch_insert.append(GameLine(
1664
- id=line_hash,
1665
- text=line_text,
1666
- scene=game_name,
1667
- time=dt,
1668
- prev=None,
1669
- next=None,
1670
- index=0,
1671
- ))
1672
-
1673
- logger.info(f"Batch insert size: {len(batch_insert)}")
1674
-
1675
- existing_uuids.add(line_hash) # Add to existing to prevent duplicates in same import
1676
-
1677
- if len(batch_insert) >= batch_size:
1678
- logger.info(f"Importing batch of {len(batch_insert)} lines...")
1679
- GameLinesTable.add_lines(batch_insert)
1680
- imported_count += len(batch_insert)
1681
- batch_insert = []
1682
- games_set.add(game_name)
1683
-
1684
- except Exception as e:
1685
- logger.error(f"Error processing row {row_num}: {e}")
1686
- errors.append(f"Row {row_num}: Error processing row - {str(e)}")
1687
- continue
1688
-
1689
- # Insert the rest of the batch
1690
- if batch_insert:
1691
- logger.info(f"Importing final batch of {len(batch_insert)} lines...")
1692
- GameLinesTable.add_lines(batch_insert)
1693
- imported_count += len(batch_insert)
1694
- batch_insert = []
1695
-
1696
- # # Import lines into database
1697
- # imported_count = 0
1698
- # for game_line in imported_lines:
1699
- # try:
1700
- # game_line.add()
1701
- # imported_count += 1
1702
- # except Exception as e:
1703
- # logger.error(f"Failed to import line {game_line.id}: {e}")
1704
- # errors.append(f"Failed to import line {game_line.id}: {str(e)}")
1705
-
1706
- # Prepare response
1707
- response_data = {
1708
- 'message': f'Successfully imported {imported_count} lines from {len(games_set)} games',
1709
- 'imported_count': imported_count,
1710
- 'games_count': len(games_set),
1711
- 'games': list(games_set)
1712
- }
1713
-
1714
- if errors:
1715
- response_data['warnings'] = errors
1716
- response_data['warning_count'] = len(errors)
1717
-
1718
- logger.info(f"ExStatic import completed: {imported_count} lines from {len(games_set)} games")
1719
-
1720
- logger.info(f"Import response: {response_data}")
1721
-
1722
1536
  return jsonify(response_data), 200
1723
-
1724
- except csv.Error as e:
1725
- return jsonify({'error': f'CSV parsing error: {str(e)}'}), 400
1726
- except UnicodeDecodeError:
1727
- return jsonify({'error': 'File encoding error. Please ensure the CSV is UTF-8 encoded.'}), 400
1728
-
1729
- except Exception as e:
1730
- logger.error(f"Error in ExStatic import: {e}")
1731
- return jsonify({'error': f'Import failed: {str(e)}'}), 500
1732
-
1733
- @app.route('/api/kanji-sorting-configs', methods=['GET'])
1734
- def api_kanji_sorting_configs():
1735
- """
1736
- List available kanji sorting configuration JSON files.
1737
- Returns metadata for each available sorting option.
1738
- """
1739
- try:
1740
- # Get the kanji_grid directory path
1741
- template_dir = Path(__file__).parent / 'templates' / 'components' / 'kanji_grid'
1742
-
1743
- if not template_dir.exists():
1744
- logger.warning(f"Kanji grid directory does not exist: {template_dir}")
1745
- return jsonify({'configs': []}), 200
1746
-
1747
- configs = []
1748
-
1749
- # Scan for JSON files in the directory
1750
- for json_file in template_dir.glob('*.json'):
1751
- try:
1752
- with open(json_file, 'r', encoding='utf-8') as f:
1753
- data = json.load(f)
1754
-
1755
- # Extract metadata from JSON
1756
- configs.append({
1757
- 'filename': json_file.name,
1758
- 'name': data.get('name', json_file.stem),
1759
- 'version': data.get('version', 1),
1760
- 'lang': data.get('lang', 'ja'),
1761
- 'source': data.get('source', ''),
1762
- 'group_count': len(data.get('groups', []))
1763
- })
1764
- except json.JSONDecodeError as e:
1765
- logger.warning(f"Failed to parse {json_file.name}: {e}")
1766
- continue
1767
- except Exception as e:
1768
- logger.warning(f"Error reading {json_file.name}: {e}")
1769
- continue
1770
-
1771
- # Sort by name for consistency
1772
- configs.sort(key=lambda x: x['name'])
1773
-
1774
- return jsonify({'configs': configs}), 200
1775
-
1776
- except Exception as e:
1777
- logger.error(f"Error fetching kanji sorting configs: {e}")
1778
- return jsonify({'error': 'Failed to fetch sorting configurations'}), 500
1779
-
1780
- @app.route('/api/kanji-sorting-config/<filename>', methods=['GET'])
1781
- def api_kanji_sorting_config(filename):
1782
- """
1783
- Get a specific kanji sorting configuration file.
1784
- Returns the full JSON configuration.
1785
- """
1786
- try:
1787
- # Sanitize filename to prevent path traversal
1788
- if '..' in filename or '/' in filename or '\\' in filename:
1789
- return jsonify({'error': 'Invalid filename'}), 400
1790
-
1791
- if not filename.endswith('.json'):
1792
- filename += '.json'
1793
-
1794
- # Get the kanji_grid directory path
1795
- template_dir = Path(__file__).parent / 'templates' / 'components' / 'kanji_grid'
1796
- config_file = template_dir / filename
1797
-
1798
- if not config_file.exists() or not config_file.is_file():
1799
- return jsonify({'error': 'Configuration file not found'}), 404
1800
-
1801
- # Read and return the JSON configuration
1802
- with open(config_file, 'r', encoding='utf-8') as f:
1803
- config_data = json.load(f)
1804
-
1805
- return jsonify(config_data), 200
1806
-
1807
- except json.JSONDecodeError as e:
1808
- logger.error(f"Failed to parse {filename}: {e}")
1809
- return jsonify({'error': 'Invalid JSON configuration'}), 500
1810
- except Exception as e:
1811
- logger.error(f"Error fetching config {filename}: {e}")
1812
- return jsonify({'error': 'Failed to fetch configuration'}), 500
1813
1537
 
1814
- @app.route('/api/debug-db', methods=['GET'])
1815
- def api_debug_db():
1816
- """Debug endpoint to check database structure and content."""
1817
- try:
1818
- # Check table structure
1819
- columns_info = GameLinesTable._db.fetchall("PRAGMA table_info(game_lines)")
1820
- table_structure = [{'name': col[1], 'type': col[2], 'notnull': col[3], 'default': col[4]} for col in columns_info]
1821
-
1822
- # Check if we have any data
1823
- count_result = GameLinesTable._db.fetchone("SELECT COUNT(*) FROM game_lines")
1824
- total_count = count_result[0] if count_result else 0
1825
-
1826
- # Try to get a sample record
1827
- sample_record = None
1828
- if total_count > 0:
1829
- sample_row = GameLinesTable._db.fetchone("SELECT * FROM game_lines LIMIT 1")
1830
- if sample_row:
1831
- sample_record = {
1832
- 'row_length': len(sample_row),
1833
- 'sample_data': sample_row[:5] if len(sample_row) > 5 else sample_row # First 5 columns only
1538
+ except Exception as db_error:
1539
+ logger.error(
1540
+ f"Database error during game merge: {db_error}", exc_info=True
1541
+ )
1542
+ return jsonify(
1543
+ {
1544
+ "error": f"Failed to merge games due to database error: {str(db_error)}"
1834
1545
  }
1835
-
1836
- # Test the model
1837
- model_info = {
1838
- 'fields_count': len(GameLinesTable._fields),
1839
- 'types_count': len(GameLinesTable._types),
1840
- 'fields': GameLinesTable._fields,
1841
- 'types': [str(t) for t in GameLinesTable._types]
1842
- }
1843
-
1844
- return jsonify({
1845
- 'table_structure': table_structure,
1846
- 'total_records': total_count,
1847
- 'sample_record': sample_record,
1848
- 'model_info': model_info
1849
- }), 200
1850
-
1851
- except Exception as e:
1852
- logger.error(f"Error in debug endpoint: {e}")
1853
- return jsonify({'error': f'Debug failed: {str(e)}'}), 500
1546
+ ), 500
1854
1547
 
1548
+ except Exception as e:
1549
+ logger.error(f"Error in game merge API: {e}")
1550
+ return jsonify({"error": f"Game merge failed: {str(e)}"}), 500