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
@@ -6,6 +6,7 @@ import threading
6
6
 
7
7
  import flask
8
8
  import webbrowser
9
+ from flask import make_response
9
10
 
10
11
  from GameSentenceMiner.ai.ai_prompting import get_ai_prompt_result
11
12
  from GameSentenceMiner.obs import get_current_game
@@ -13,26 +14,47 @@ from GameSentenceMiner.util.gsm_utils import TEXT_REPLACEMENTS_FILE
13
14
  from GameSentenceMiner.util.text_log import get_line_by_id, get_all_lines
14
15
  from flask import render_template, request, jsonify, send_from_directory
15
16
  from GameSentenceMiner import obs
16
- from GameSentenceMiner.util.configuration import logger, get_config, gsm_state, gsm_status
17
+ from GameSentenceMiner.util.configuration import (
18
+ logger,
19
+ get_config,
20
+ gsm_state,
21
+ gsm_status,
22
+ )
17
23
  from GameSentenceMiner.web.service import handle_texthooker_button
18
24
 
19
25
  # Import from new modules
20
- from GameSentenceMiner.web.events import (
21
- EventManager, event_manager
22
- )
26
+ from GameSentenceMiner.web.events import EventManager, event_manager
23
27
  from GameSentenceMiner.web.stats import (
24
- is_kanji, interpolate_color, get_gradient_color, calculate_kanji_frequency,
25
- calculate_heatmap_data, calculate_total_chars_per_game, calculate_reading_time_per_game,
26
- calculate_reading_speed_per_game, generate_game_colors, format_large_number,
27
- calculate_actual_reading_time, calculate_daily_reading_time, calculate_time_based_streak,
28
- format_time_human_readable, calculate_current_game_stats, calculate_all_games_stats
28
+ is_kanji,
29
+ interpolate_color,
30
+ get_gradient_color,
31
+ calculate_kanji_frequency,
32
+ calculate_heatmap_data,
33
+ calculate_total_chars_per_game,
34
+ calculate_reading_time_per_game,
35
+ calculate_reading_speed_per_game,
36
+ generate_game_colors,
37
+ format_large_number,
38
+ calculate_actual_reading_time,
39
+ calculate_daily_reading_time,
40
+ calculate_time_based_streak,
41
+ format_time_human_readable,
42
+ calculate_current_game_stats,
43
+ calculate_all_games_stats,
29
44
  )
30
45
  from GameSentenceMiner.web.gsm_websocket import (
31
- WebsocketServerThread, websocket_queue, paused, websocket_server_thread,
32
- plaintext_websocket_server_thread, overlay_server_thread, websocket_server_threads,
33
- handle_exit_signal
46
+ WebsocketServerThread,
47
+ websocket_queue,
48
+ paused,
49
+ websocket_server_thread,
50
+ plaintext_websocket_server_thread,
51
+ overlay_server_thread,
52
+ websocket_server_threads,
53
+ handle_exit_signal,
34
54
  )
35
55
  from GameSentenceMiner.web.database_api import register_database_api_routes
56
+ from GameSentenceMiner.web.jiten_database_api import register_jiten_database_api_routes
57
+ from GameSentenceMiner.web.stats_api import register_stats_api_routes
36
58
 
37
59
  # Global configuration
38
60
  port = get_config().general.texthooker_port
@@ -43,27 +65,102 @@ server_start_time = datetime.datetime.now().timestamp()
43
65
 
44
66
  app = flask.Flask(__name__)
45
67
 
68
+ # Configure Flask-Compress for Brotli compression
69
+ try:
70
+ from flask_compress import Compress
71
+
72
+ # Configure compression settings
73
+ app.config["COMPRESS_MIMETYPES"] = [
74
+ "text/html",
75
+ "text/css",
76
+ "text/xml",
77
+ "text/plain",
78
+ "application/json",
79
+ "application/javascript",
80
+ "application/x-javascript",
81
+ "text/javascript",
82
+ ]
83
+ app.config["COMPRESS_LEVEL"] = 6 # Balance between speed and compression ratio
84
+ app.config["COMPRESS_MIN_SIZE"] = 500 # Only compress files larger than 500 bytes
85
+ app.config["COMPRESS_ALGORITHM"] = [
86
+ "br",
87
+ "gzip",
88
+ "deflate",
89
+ ] # Prefer Brotli, fallback to gzip
90
+
91
+ Compress(app)
92
+ logger.info("Flask compression enabled with Brotli support")
93
+ except ImportError:
94
+ logger.warning(
95
+ "flask-compress not installed. Run 'pip install flask-compress' for better performance."
96
+ )
97
+
98
+
99
+ # Add cache control headers for static files
100
+ @app.after_request
101
+ def add_cache_headers(response):
102
+ """Add cache control headers to static assets for better performance."""
103
+ # Only add cache headers for static files (CSS, JS, images, fonts)
104
+ if request.path.startswith("/static/"):
105
+ # Check file extension
106
+ if any(request.path.endswith(ext) for ext in [".css", ".js"]):
107
+ # No cache for CSS/JS - always fetch fresh content
108
+ response.cache_control.no_cache = True
109
+ response.cache_control.no_store = True
110
+ response.cache_control.must_revalidate = True
111
+ response.headers["Cache-Control"] = (
112
+ "no-cache, no-store, must-revalidate"
113
+ )
114
+ response.headers["Pragma"] = "no-cache"
115
+ response.headers["Expires"] = "0"
116
+ elif any(
117
+ request.path.endswith(ext)
118
+ for ext in [
119
+ ".jpg",
120
+ ".jpeg",
121
+ ".png",
122
+ ".gif",
123
+ ".svg",
124
+ ".woff",
125
+ ".woff2",
126
+ ".ttf",
127
+ ".eot",
128
+ ".ico",
129
+ ]
130
+ ):
131
+ # Cache images and fonts for longer (they rarely change)
132
+ response.cache_control.max_age = 2592000 # 30 days
133
+ response.cache_control.public = True
134
+ response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
135
+ return response
136
+
137
+
46
138
  # Register database API routes
47
139
  register_database_api_routes(app)
140
+ register_jiten_database_api_routes(app)
141
+ register_stats_api_routes(app)
48
142
 
49
143
  # Register Anki API routes
50
144
  from GameSentenceMiner.web.anki_api_endpoints import register_anki_api_endpoints
145
+
51
146
  register_anki_api_endpoints(app)
52
147
 
148
+
53
149
  # Load data from the JSON file
54
150
  def load_data_from_file():
55
151
  if os.path.exists(TEXT_REPLACEMENTS_FILE):
56
- with open(TEXT_REPLACEMENTS_FILE, 'r', encoding='utf-8') as file:
152
+ with open(TEXT_REPLACEMENTS_FILE, "r", encoding="utf-8") as file:
57
153
  return json.load(file)
58
154
  return {"enabled": True, "args": {"replacements": {}}}
59
155
 
156
+
60
157
  # Save data to the JSON file
61
158
  def save_data_to_file(data):
62
- with open(TEXT_REPLACEMENTS_FILE, 'w', encoding='utf-8') as file:
159
+ with open(TEXT_REPLACEMENTS_FILE, "w", encoding="utf-8") as file:
63
160
  json.dump(data, file, indent=4, ensure_ascii=False)
64
161
 
65
162
 
66
- @app.route('/load-data', methods=['GET'])
163
+ @app.route("/load-data", methods=["GET"])
67
164
  def load_data():
68
165
  try:
69
166
  data = load_data_from_file()
@@ -72,7 +169,7 @@ def load_data():
72
169
  return jsonify({"error": f"Failed to load data: {str(e)}"}), 500
73
170
 
74
171
 
75
- @app.route('/save-data', methods=['POST'])
172
+ @app.route("/save-data", methods=["POST"])
76
173
  def save_data():
77
174
  try:
78
175
  data = request.get_json()
@@ -87,33 +184,36 @@ def save_data():
87
184
 
88
185
 
89
186
  def inject_server_start_time(html_content, timestamp):
90
- placeholder = '<script>'
91
- replacement = f'<script>const serverStartTime = {timestamp};'
187
+ placeholder = "<script>"
188
+ replacement = f"<script>const serverStartTime = {timestamp};"
92
189
  return html_content.replace(placeholder, replacement)
93
190
 
94
191
 
95
- @app.route('/favicon.ico')
192
+ @app.route("/favicon.ico")
96
193
  def favicon():
97
- return send_from_directory(os.path.join(app.root_path, 'static'),
98
- 'favicon.ico', mimetype='image/vnd.microsoft.icon')
194
+ return send_from_directory(
195
+ os.path.join(app.root_path, "static"),
196
+ "favicon.ico",
197
+ mimetype="image/vnd.microsoft.icon",
198
+ )
99
199
 
100
200
 
101
- @app.route('/<path:filename>')
201
+ @app.route("/<path:filename>")
102
202
  def serve_static(filename):
103
- return send_from_directory('pages', filename)
203
+ return send_from_directory("pages", filename)
104
204
 
105
205
 
106
- @app.route('/')
206
+ @app.route("/")
107
207
  def index():
108
- return send_from_directory('templates', 'index.html')
208
+ return send_from_directory("templates", "index.html")
109
209
 
110
210
 
111
- @app.route('/texthooker')
211
+ @app.route("/texthooker")
112
212
  def texthooker():
113
- return send_from_directory('templates', 'index.html')
213
+ return send_from_directory("templates", "index.html")
114
214
 
115
215
 
116
- @app.route('/textreplacements')
216
+ @app.route("/textreplacements")
117
217
  def textreplacements():
118
218
  # Serve the text replacements data as JSON for compatibility
119
219
  try:
@@ -125,48 +225,59 @@ def textreplacements():
125
225
  except Exception as e:
126
226
  return jsonify({"error": f"Failed to load text replacements: {str(e)}"}), 500
127
227
 
128
- @app.route('/database')
228
+
229
+ @app.route("/database")
129
230
  def database():
130
- return flask.render_template('database.html')
231
+ return flask.render_template("database.html")
131
232
 
132
233
 
133
- @app.route('/data', methods=['GET'])
234
+ @app.route("/data", methods=["GET"])
134
235
  def get_data():
135
236
  return jsonify([event.to_dict() for event in event_manager])
136
237
 
137
238
 
138
- @app.route('/get_ids', methods=['GET'])
239
+ @app.route("/get_ids", methods=["GET"])
139
240
  def get_ids():
140
241
  asyncio.run(check_for_lines_outside_replay_buffer())
141
- return jsonify({
142
- "ids": list(event_manager.get_ids()),
143
- "timed_out_ids": list(event_manager.timed_out_ids)
144
- })
242
+ return jsonify(
243
+ {
244
+ "ids": list(event_manager.get_ids()),
245
+ "timed_out_ids": list(event_manager.timed_out_ids),
246
+ }
247
+ )
145
248
 
146
249
 
147
- @app.route('/clear_history', methods=['POST'])
250
+ @app.route("/clear_history", methods=["POST"])
148
251
  def clear_history():
149
252
  temp_em = EventManager()
150
253
  temp_em.clear_history()
151
254
  temp_em.close_connection()
152
- return jsonify({'message': 'History cleared successfully'}), 200
255
+ return jsonify({"message": "History cleared successfully"}), 200
153
256
 
154
257
 
155
258
  async def check_for_lines_outside_replay_buffer():
156
- time_window = datetime.datetime.now() - datetime.timedelta(seconds=gsm_state.replay_buffer_length) - datetime.timedelta(seconds=5)
259
+ time_window = (
260
+ datetime.datetime.now()
261
+ - datetime.timedelta(seconds=gsm_state.replay_buffer_length)
262
+ - datetime.timedelta(seconds=5)
263
+ )
157
264
  # logger.info(f"Checking for lines outside replay buffer time window: {time_window}")
158
- lines_outside_buffer = [line.id for line in event_manager.get_events() if line.time < time_window]
265
+ lines_outside_buffer = [
266
+ line.id for line in event_manager.get_events() if line.time < time_window
267
+ ]
159
268
  # logger.info(f"Lines outside replay buffer: {lines_outside_buffer}")
160
269
  event_manager.remove_lines_by_ids(lines_outside_buffer, timed_out=True)
161
-
270
+
162
271
 
163
272
  async def add_event_to_texthooker(line):
164
273
  new_event = event_manager.add_gameline(line)
165
- await websocket_server_thread.send_text({
166
- 'event': 'text_received',
167
- 'sentence': line.text,
168
- 'data': new_event.to_serializable()
169
- })
274
+ await websocket_server_thread.send_text(
275
+ {
276
+ "event": "text_received",
277
+ "sentence": line.text,
278
+ "data": new_event.to_serializable(),
279
+ }
280
+ )
170
281
  if get_config().advanced.plaintext_websocket_port:
171
282
  await plaintext_websocket_server_thread.send_text(line.text)
172
283
  await check_for_lines_outside_replay_buffer()
@@ -177,61 +288,71 @@ async def send_word_coordinates_to_overlay(boxes):
177
288
  await overlay_server_thread.send_text(boxes)
178
289
 
179
290
 
180
- @app.route('/update_checkbox', methods=['POST'])
291
+ @app.route("/update_checkbox", methods=["POST"])
181
292
  def update_event():
182
293
  data = request.get_json()
183
- event_id = data.get('id')
294
+ event_id = data.get("id")
184
295
 
185
296
  if event_id is None:
186
- return jsonify({'error': 'Missing id'}), 400
297
+ return jsonify({"error": "Missing id"}), 400
187
298
  event = event_manager.get(event_id)
188
299
  event_manager.get(event_id).checked = not event.checked
189
- return jsonify({'message': 'Event updated successfully'}), 200
300
+ return jsonify({"message": "Event updated successfully"}), 200
190
301
 
191
302
 
192
- @app.route('/get-screenshot', methods=['Post'])
303
+ @app.route("/get-screenshot", methods=["Post"])
193
304
  def get_screenshot():
194
305
  """Endpoint to get a screenshot of the current game screen."""
195
306
  data = request.get_json()
196
- event_id = data.get('id')
307
+ event_id = data.get("id")
197
308
  if event_id is None:
198
- return jsonify({'error': 'Missing id'}), 400
309
+ return jsonify({"error": "Missing id"}), 400
199
310
  line = get_line_by_id(event_id)
200
311
  if not line:
201
- return jsonify({'error': 'Invalid id'}), 400
312
+ return jsonify({"error": "Invalid id"}), 400
202
313
  gsm_state.line_for_screenshot = line
203
- if gsm_state.previous_line_for_screenshot and gsm_state.line_for_screenshot == gsm_state.previous_line_for_screenshot or gsm_state.previous_line_for_audio and gsm_state.line_for_screenshot == gsm_state.previous_line_for_audio:
314
+ if (
315
+ gsm_state.previous_line_for_screenshot
316
+ and gsm_state.line_for_screenshot == gsm_state.previous_line_for_screenshot
317
+ or gsm_state.previous_line_for_audio
318
+ and gsm_state.line_for_screenshot == gsm_state.previous_line_for_audio
319
+ ):
204
320
  handle_texthooker_button(gsm_state.previous_replay)
205
321
  else:
206
322
  obs.save_replay_buffer()
207
323
  return jsonify({}), 200
208
324
 
209
325
 
210
- @app.route('/play-audio', methods=['POST'])
326
+ @app.route("/play-audio", methods=["POST"])
211
327
  def play_audio():
212
328
  """Endpoint to play audio for a specific event."""
213
329
  data = request.get_json()
214
- event_id = data.get('id')
330
+ event_id = data.get("id")
215
331
  if event_id is None:
216
- return jsonify({'error': 'Missing id'}), 400
332
+ return jsonify({"error": "Missing id"}), 400
217
333
  print(f"Playing audio for event ID: {event_id}")
218
334
  line = get_line_by_id(event_id)
219
335
  if not line:
220
- return jsonify({'error': 'Invalid id'}), 400
336
+ return jsonify({"error": "Invalid id"}), 400
221
337
  gsm_state.line_for_audio = line
222
338
  print(f"gsm_state.line_for_audio: {gsm_state.line_for_audio}")
223
- if gsm_state.previous_line_for_audio and gsm_state.line_for_audio == gsm_state.previous_line_for_audio or gsm_state.previous_line_for_screenshot and gsm_state.line_for_audio == gsm_state.previous_line_for_screenshot:
339
+ if (
340
+ gsm_state.previous_line_for_audio
341
+ and gsm_state.line_for_audio == gsm_state.previous_line_for_audio
342
+ or gsm_state.previous_line_for_screenshot
343
+ and gsm_state.line_for_audio == gsm_state.previous_line_for_screenshot
344
+ ):
224
345
  handle_texthooker_button(gsm_state.previous_replay)
225
346
  else:
226
347
  obs.save_replay_buffer()
227
348
  return jsonify({}), 200
228
349
 
229
350
 
230
- @app.route("/translate-line", methods=['POST'])
351
+ @app.route("/translate-line", methods=["POST"])
231
352
  def translate_line():
232
353
  data = request.get_json()
233
- event_id = data.get('id')
234
- text = data.get('text', '').strip()
354
+ event_id = data.get("id")
355
+ text = data.get("text", "").strip()
235
356
  if event_id is None:
236
357
  return jsonify({'error': 'Missing id'}), 400
237
358
 
@@ -254,34 +375,46 @@ def translate_line():
254
375
  """
255
376
 
256
377
  if not get_config().ai.is_configured():
257
- return jsonify({'error': 'AI translation is not properly configured. Please check your settings in the "AI" Tab.'}), 400
378
+ return jsonify(
379
+ {
380
+ "error": 'AI translation is not properly configured. Please check your settings in the "AI" Tab.'
381
+ }
382
+ ), 400
258
383
  line = get_line_by_id(event_id)
259
384
  if line is None:
260
- return jsonify({'error': 'Invalid id'}), 400
385
+ return jsonify({"error": "Invalid id"}), 400
261
386
  line_to_translate = text if text else line.text
262
387
  translation = get_ai_prompt_result(
263
388
  get_all_lines(), line_to_translate, line, get_current_game(), custom_prompt=prompt
264
389
  )
265
390
  line.set_TL(translation)
266
- return jsonify({'TL': translation}), 200
391
+ return jsonify({"TL": translation}), 200
392
+
267
393
 
268
- @app.route('/translate-multiple', methods=['POST'])
394
+ @app.route("/translate-multiple", methods=["POST"])
269
395
  def translate_multiple():
270
396
  data = request.get_json()
271
- event_ids = data.get('ids', [])
397
+ event_ids = data.get("ids", [])
272
398
  if not event_ids:
273
- return jsonify({'error': 'Missing ids'}), 400
274
-
275
- if not get_config().ai.is_configured():
276
- return jsonify({'error': 'AI translation is not properly configured. Please check your settings in the "AI" Tab.'}), 400
399
+ return jsonify({"error": "Missing ids"}), 400
277
400
 
278
- lines = [get_line_by_id(event_id) for event_id in event_ids if get_line_by_id(event_id) is not None]
401
+ if not get_config().ai.is_configured():
402
+ return jsonify(
403
+ {
404
+ "error": 'AI translation is not properly configured. Please check your settings in the "AI" Tab.'
405
+ }
406
+ ), 400
407
+
408
+ lines = [
409
+ get_line_by_id(event_id)
410
+ for event_id in event_ids
411
+ if get_line_by_id(event_id) is not None
412
+ ]
279
413
 
280
414
  text = "\n".join(line.text for line in lines)
281
415
 
282
416
  language = get_config().general.get_native_language_name() if get_config().general.native_language else "English"
283
417
 
284
-
285
418
  translate_multiple_lines_prompt = f"""
286
419
  **Professional Game Localization Task**
287
420
  Translate the following lines of game dialogue into natural-sounding, context-aware {language}:
@@ -296,62 +429,87 @@ def translate_multiple():
296
429
  **Lines to Translate:**
297
430
  """
298
431
 
299
- translation = get_ai_prompt_result(get_all_lines(), text,
300
- lines[0], get_current_game(), custom_prompt=translate_multiple_lines_prompt)
432
+ translation = get_ai_prompt_result(
433
+ get_all_lines(),
434
+ text,
435
+ lines[0],
436
+ get_current_game(),
437
+ custom_prompt=translate_multiple_lines_prompt,
438
+ )
301
439
 
302
440
  return translation, 200
303
441
 
304
- @app.route('/get_status', methods=['GET'])
442
+
443
+ @app.route("/get_status", methods=["GET"])
305
444
  def get_status():
306
445
  return jsonify(gsm_status.to_dict()), 200
307
446
 
308
- @app.template_filter('datetimeformat')
309
- def datetimeformat(value, format='%Y-%m-%d %H:%M:%S'):
447
+
448
+ @app.template_filter("datetimeformat")
449
+ def datetimeformat(value, format="%Y-%m-%d %H:%M:%S"):
310
450
  """Formats a timestamp into a human-readable string."""
311
451
  if value is None:
312
452
  return ""
313
453
  return datetime.datetime.fromtimestamp(float(value)).strftime(format)
314
454
 
315
455
 
316
- @app.route('/overview')
456
+ @app.route("/overview")
317
457
  def overview():
318
458
  """Renders the overview page."""
319
459
  from GameSentenceMiner.util.configuration import get_master_config, get_stats_config
320
- return render_template('overview.html',
321
- config=get_config(),
322
- master_config=get_master_config(),
323
- stats_config=get_stats_config())
324
460
 
325
- @app.route('/stats')
461
+ return render_template(
462
+ "overview.html",
463
+ config=get_config(),
464
+ master_config=get_master_config(),
465
+ stats_config=get_stats_config(),
466
+ )
467
+
468
+
469
+ @app.route("/stats")
326
470
  def stats():
327
471
  """Renders the stats page."""
328
472
  from GameSentenceMiner.util.configuration import get_master_config, get_stats_config
329
- return render_template('stats.html',
330
- config=get_config(),
331
- master_config=get_master_config(),
332
- stats_config=get_stats_config())
473
+ from GameSentenceMiner.util.stats_rollup_table import StatsRollupTable
474
+
475
+ # Get first date from rollup table to avoid extra API call on page load
476
+ first_rollup_date = StatsRollupTable.get_first_date()
477
+
478
+ return render_template(
479
+ "stats.html",
480
+ config=get_config(),
481
+ master_config=get_master_config(),
482
+ stats_config=get_stats_config(),
483
+ first_rollup_date=first_rollup_date,
484
+ )
333
485
 
334
- @app.route('/goals')
486
+
487
+ @app.route("/goals")
335
488
  def goals():
336
489
  """Renders the goals page."""
337
490
  from GameSentenceMiner.util.configuration import get_master_config, get_stats_config
338
- return render_template('goals.html',
339
- config=get_config(),
340
- master_config=get_master_config(),
341
- stats_config=get_stats_config())
491
+
492
+ return render_template(
493
+ "goals.html",
494
+ config=get_config(),
495
+ master_config=get_master_config(),
496
+ stats_config=get_stats_config(),
497
+ )
342
498
 
343
499
 
344
- @app.route('/search')
500
+ @app.route("/search")
345
501
  def search():
346
502
  """Renders the search page."""
347
- return render_template('search.html')
503
+ return render_template("search.html")
348
504
 
349
- @app.route('/anki_stats')
505
+
506
+ @app.route("/anki_stats")
350
507
  def anki_stats():
351
508
  """Renders the Anki statistics page."""
352
- return render_template('anki_stats.html')
509
+ return render_template("anki_stats.html")
510
+
353
511
 
354
- @app.route('/get_websocket_port', methods=['GET'])
512
+ @app.route("/get_websocket_port", methods=["GET"])
355
513
  def get_websocket_port():
356
514
  return jsonify({"port": websocket_server_thread.get_ws_port_func()}), 200
357
515
 
@@ -366,28 +524,36 @@ def are_lines_selected():
366
524
 
367
525
  def reset_checked_lines():
368
526
  async def send_reset_message():
369
- await websocket_server_thread.send_text({
370
- 'event': 'reset_checkboxes',
371
- })
527
+ await websocket_server_thread.send_text(
528
+ {
529
+ "event": "reset_checkboxes",
530
+ }
531
+ )
532
+
372
533
  event_manager.reset_checked_lines()
373
534
  asyncio.run(send_reset_message())
374
-
535
+
536
+
375
537
  def reset_buttons():
376
538
  async def send_reset_message():
377
- await websocket_server_thread.send_text({
378
- 'event': 'reset_buttons',
379
- })
539
+ await websocket_server_thread.send_text(
540
+ {
541
+ "event": "reset_buttons",
542
+ }
543
+ )
544
+
380
545
  asyncio.run(send_reset_message())
381
546
 
382
547
 
383
548
  def open_texthooker():
384
- webbrowser.open(url + '/texthooker')
549
+ webbrowser.open(url + "/texthooker")
385
550
 
386
551
 
387
552
  def start_web_server():
388
553
  logger.debug("Starting web server...")
389
554
  import logging
390
- log = logging.getLogger('werkzeug')
555
+
556
+ log = logging.getLogger("werkzeug")
391
557
  log.setLevel(logging.ERROR) # Set to ERROR to suppress most logs
392
558
 
393
559
  # Open the default browser
@@ -401,7 +567,10 @@ def start_web_server():
401
567
 
402
568
 
403
569
  async def texthooker_page_coro():
404
- global websocket_server_thread, plaintext_websocket_server_thread, overlay_server_thread
570
+ global \
571
+ websocket_server_thread, \
572
+ plaintext_websocket_server_thread, \
573
+ overlay_server_thread
405
574
  # Run the WebSocket server in the asyncio event loop
406
575
  flask_thread = threading.Thread(target=start_web_server)
407
576
  flask_thread.daemon = True
@@ -417,5 +586,5 @@ def run_text_hooker_page():
417
586
  logger.info("Shutting down due to KeyboardInterrupt.")
418
587
 
419
588
 
420
- if __name__ == '__main__':
421
- asyncio.run(texthooker_page_coro())
589
+ if __name__ == "__main__":
590
+ asyncio.run(texthooker_page_coro())