GameSentenceMiner 2.16.2__py3-none-any.whl → 2.16.3__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.
@@ -391,9 +391,10 @@ class ConfigApp:
391
391
  self.use_canned_context_prompt_value = tk.BooleanVar(value=self.settings.ai.use_canned_context_prompt)
392
392
  self.ai_dialogue_context_length_value = tk.StringVar(value=str(self.settings.ai.dialogue_context_length))
393
393
 
394
- # WIP Settings
394
+ # Overlay Settings
395
395
  self.overlay_websocket_port_value = tk.StringVar(value=str(self.settings.overlay.websocket_port))
396
396
  self.overlay_websocket_send_value = tk.BooleanVar(value=self.settings.overlay.monitor_to_capture)
397
+ self.overlay_engine_value = tk.StringVar(value=self.settings.overlay.engine)
397
398
 
398
399
  # Master Config Settings
399
400
  self.switch_to_default_if_not_found_value = tk.BooleanVar(value=self.master_config.switch_to_default_if_not_found)
@@ -623,7 +624,8 @@ class ConfigApp:
623
624
  ),
624
625
  overlay=Overlay(
625
626
  websocket_port=int(self.overlay_websocket_port_value.get()),
626
- monitor_to_capture=self.overlay_monitor.current() if self.monitors else 0
627
+ monitor_to_capture=self.overlay_monitor.current() if self.monitors else 0,
628
+ engine=OverlayEngine(self.overlay_engine_value.get()).value if self.overlay_engine_value.get() else OverlayEngine.LENS.value
627
629
  )
628
630
  # wip=WIP(
629
631
  # overlay_websocket_port=int(self.overlay_websocket_port_value.get()),
@@ -2221,7 +2223,17 @@ class ConfigApp:
2221
2223
  self.overlay_monitor.current(0)
2222
2224
  self.overlay_monitor.config(state="disabled")
2223
2225
  self.current_row += 1
2224
-
2226
+
2227
+ # Overlay Engine Selection
2228
+ overlay_engine_i18n = overlay_i18n.get('overlay_engine', {})
2229
+ HoverInfoLabelWidget(overlay_frame, text=overlay_engine_i18n.get('label', '...'),
2230
+ tooltip=overlay_engine_i18n.get('tooltip', '...'),
2231
+ row=self.current_row, column=0)
2232
+ self.overlay_engine = ttk.Combobox(overlay_frame, values=[e.value for e in OverlayEngine], state="readonly",
2233
+ textvariable=self.overlay_engine_value)
2234
+ self.overlay_engine.grid(row=self.current_row, column=1, sticky='EW', pady=2)
2235
+ self.current_row += 1
2236
+
2225
2237
  if self.monitors:
2226
2238
  # Ensure the index is valid
2227
2239
  monitor_index = self.settings.overlay.monitor_to_capture
@@ -568,6 +568,10 @@
568
568
  "label": "Monitor to Capture:",
569
569
  "tooltip": "Select the monitor to capture (1-based index).",
570
570
  "not_detected": "OwOCR Not Detected"
571
+ },
572
+ "overlay_engine": {
573
+ "label": "Overlay Engine:",
574
+ "tooltip": "Select the OCR engine for the overlay. If you use lens, and are on windows, it will use OneOCR to optimize the scan."
571
575
  }
572
576
  },
573
577
  "wip": {
@@ -567,6 +567,10 @@
567
567
  "label": "キャプチャ対象モニター:",
568
568
  "tooltip": "キャプチャするモニターを選択(1から始まるインデックス)。",
569
569
  "not_detected": "OwOCRが検出されません"
570
+ },
571
+ "overlay_engine": {
572
+ "label": "オーバーレイエンジン:",
573
+ "tooltip": "オーバーレイのOCRエンジンを選択します。Lensを使用していてWindowsの場合、スキャンを最適化するためにOneOCRを使用します。"
570
574
  }
571
575
  },
572
576
  "wip": {
@@ -556,6 +556,10 @@
556
556
  "label": "捕获的显示器:",
557
557
  "tooltip": "选择要捕获的显示器(从1开始的索引)。",
558
558
  "not_detected": "未检测到 OwOCR"
559
+ },
560
+ "overlay_engine": {
561
+ "label": "覆盖层引擎:",
562
+ "tooltip": "为覆盖层选择 OCR 引擎。如果您使用的是 lens,并且在 windows 上,它将使用 OneOCR 来优化扫描。"
559
563
  }
560
564
  },
561
565
  "wip": {
@@ -630,10 +630,15 @@ class Ai:
630
630
  self.gemini_model = 'gemini-2.5-flash-lite'
631
631
 
632
632
 
633
+ class OverlayEngine(str, Enum):
634
+ LENS = 'lens'
635
+ ONEOCR = 'oneocr'
636
+
633
637
  @dataclass_json
634
638
  @dataclass
635
639
  class Overlay:
636
640
  websocket_port: int = 55499
641
+ engine: str = OverlayEngine.LENS.value
637
642
  monitor_to_capture: int = 0
638
643
 
639
644
  def __post_init__(self):
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import shutil
5
6
  import sqlite3
6
7
  from sys import platform
7
8
  import time
@@ -10,6 +11,7 @@ import threading
10
11
 
11
12
  from GameSentenceMiner.util.text_log import GameLine
12
13
  from GameSentenceMiner.util.configuration import logger, is_dev
14
+ import gzip
13
15
 
14
16
 
15
17
  class SQLiteDB:
@@ -399,6 +401,37 @@ def get_db_directory():
399
401
  return os.path.join(config_dir, 'gsm.db')
400
402
 
401
403
 
404
+ # Backup and compress the database on load, with today's date, up to 5 days ago (clean up old backups)
405
+ def backup_db(db_path: str):
406
+ backup_dir = os.path.join(os.path.dirname(db_path), "backup", "database")
407
+ os.makedirs(backup_dir, exist_ok=True)
408
+ today = time.strftime("%Y-%m-%d")
409
+ backup_file = os.path.join(backup_dir, f"gsm_{today}.db.gz")
410
+
411
+ # Test, remove backups older than 60 minutes
412
+ # cutoff = time.time() - 60 * 60
413
+ # Clean up backups older than 5 days
414
+ cutoff = time.time() - 5 * 24 * 60 * 60
415
+ for fname in os.listdir(backup_dir):
416
+ fpath = os.path.join(backup_dir, fname)
417
+ if fname.startswith("gsm_") and fname.endswith(".db.gz"):
418
+ try:
419
+ file_time = os.path.getmtime(fpath)
420
+ if file_time < cutoff:
421
+ os.remove(fpath)
422
+ logger.info(f"Old backup removed: {fpath}")
423
+ except Exception as e:
424
+ logger.warning(f"Failed to remove old backup {fpath}: {e}")
425
+
426
+ # Create backup if not already present for today
427
+ if not os.path.exists(backup_file):
428
+ with open(db_path, "rb") as f_in, open(backup_file, "wb") as f_out:
429
+ with gzip.GzipFile(fileobj=f_out, mode="wb") as gz_out:
430
+ shutil.copyfileobj(f_in, gz_out)
431
+ logger.info(f"Database backup created: {backup_file}")
432
+
433
+ backup_db(get_db_directory())
434
+
402
435
  gsm_db = SQLiteDB(get_db_directory())
403
436
 
404
437
  for cls in [AIModelsTable, GameLinesTable]:
@@ -1,19 +1,67 @@
1
1
  import asyncio
2
2
  import io
3
3
  import base64
4
+ import json
4
5
  import math
5
6
  import os
6
7
  import time
7
8
  from PIL import Image
8
9
  from typing import Dict, Any, List, Tuple
10
+ import json
11
+ from rapidfuzz.distance import Levenshtein
9
12
 
10
13
  # Local application imports
11
14
  from GameSentenceMiner.ocr.gsm_ocr_config import set_dpi_awareness
12
- from GameSentenceMiner.util.configuration import get_config, is_windows
15
+ from GameSentenceMiner.util.configuration import OverlayEngine, get_config, is_windows, is_beangate, logger
13
16
  from GameSentenceMiner.util.electron_config import get_ocr_language
14
- from GameSentenceMiner.obs import get_screenshot_PIL, logger
17
+ from GameSentenceMiner.obs import get_screenshot_PIL
15
18
  from GameSentenceMiner.web.texthooking_page import send_word_coordinates_to_overlay
16
19
 
20
+ # def align_and_correct(ocr_json, reference_text):
21
+ # logger.info(f"Starting align_and_correct with reference_text: '{reference_text}'")
22
+ # corrected = []
23
+ # ref_chars = list(reference_text)
24
+ # logger.info(f"Reference chars: {ref_chars}")
25
+
26
+ # for block_idx, block in enumerate(ocr_json):
27
+ # logger.info(f"Processing block {block_idx}: {block}")
28
+ # ocr_chars = [w["text"] for w in block["words"]]
29
+ # ocr_str = "".join(ocr_chars)
30
+
31
+ # # Compute edit operations from OCR → Reference
32
+ # ops = Levenshtein.editops(ocr_str, "".join(ref_chars))
33
+
34
+ # corrected_words = block["words"][:]
35
+
36
+ # # Apply corrections
37
+ # for op_idx, (op, i, j) in enumerate(ops):
38
+ # logger.info(f"Operation {op_idx}: {op}, i={i}, j={j}")
39
+ # if op == "replace":
40
+ # logger.info(f"Replacing word at index {i} ('{corrected_words[i]['text']}') with reference char '{ref_chars[j]}'")
41
+ # corrected_words[i]["text"] = ref_chars[j]
42
+ # elif op == "insert":
43
+ # if i > 0:
44
+ # prev = corrected_words[i - 1]["bounding_rect"]
45
+ # bbox = prev # simple: copy neighbor bbox
46
+ # else:
47
+ # bbox = corrected_words[0]["bounding_rect"]
48
+ # corrected_words.insert(i, {
49
+ # "text": ref_chars[j],
50
+ # "bounding_rect": bbox,
51
+ # "confidence": 1.0
52
+ # })
53
+ # elif op == "delete":
54
+ # logger.info(f"Deleting word at index {i} ('{corrected_words[i]['text']}')")
55
+ # corrected_words[i]["text"] = "" # mark empty
56
+
57
+ # corrected_words = [w for w in corrected_words if w["text"]]
58
+
59
+ # block["words"] = corrected_words
60
+ # block["text"] = "".join(w["text"] for w in corrected_words)
61
+ # corrected.append(block)
62
+
63
+ # return corrected
64
+
17
65
  # Conditionally import OCR engines
18
66
  try:
19
67
  if os.path.exists(os.path.expanduser('~/.config/oneocr/oneocr.dll')):
@@ -218,6 +266,15 @@ class OverlayProcessor:
218
266
  )
219
267
  else:
220
268
  composite_image = full_screenshot
269
+
270
+ # If User Home is beangate
271
+ if is_beangate:
272
+ with open("oneocr_results.json", "w", encoding="utf-8") as f:
273
+ f.write(json.dumps(oneocr_results, ensure_ascii=False, indent=2))
274
+
275
+ if get_config().overlay.engine == OverlayEngine.ONEOCR.value and self.oneocr:
276
+ logger.info("Using OneOCR results for overlay as configured.")
277
+ return oneocr_results
221
278
 
222
279
  # 4. Use Google Lens on the cleaner composite image for higher accuracy
223
280
  res = self.lens(
GameSentenceMiner/vad.py CHANGED
@@ -202,7 +202,7 @@ class WhisperVADProcessor(VADProcessor):
202
202
  logger.info(
203
203
  "Unknown single character segment, not skipping, but logging, please report if this is a mistake: " + segment.text)
204
204
 
205
- if segment.no_speech_prob and segment.no_speech_prob > 0.6:
205
+ if segment.no_speech_prob and segment.no_speech_prob > 0.9:
206
206
  logger.debug(f"Skipping segment with high no_speech_prob: {segment.no_speech_prob} for segment {segment.text} at {segment.start}-{segment.end}")
207
207
  continue
208
208
 
@@ -211,7 +211,7 @@ class WhisperVADProcessor(VADProcessor):
211
211
  logger.debug(f"Skipping segment with low unique words: {unique_words} for segment {segment.text} at {segment.start}-{segment.end}")
212
212
  continue
213
213
 
214
- if previous_segment and segment.start - previous_segment.end > 5 and segment.no_speech_prob > .3:
214
+ if segment.seek > 0 and segment.no_speech_prob > .3:
215
215
  logger.debug(f"Skipping segment after long pause with high no_speech_prob after: {segment.no_speech_prob} for segment {segment.text} at {segment.start}-{segment.end}")
216
216
  continue
217
217
 
@@ -1,5 +1,7 @@
1
1
  import datetime
2
2
  import re
3
+ import csv
4
+ import io
3
5
  from collections import defaultdict
4
6
 
5
7
  import flask
@@ -135,8 +137,8 @@ def register_database_api_routes(app):
135
137
  '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')
136
138
  })
137
139
 
138
- # Sort by first entry date (most recent first)
139
- games_data.sort(key=lambda x: x['first_entry_date'], reverse=True)
140
+ # Sort by total characters (most characters first)
141
+ games_data.sort(key=lambda x: x['total_characters'], reverse=True)
140
142
 
141
143
  return jsonify({'games': games_data}), 200
142
144
 
@@ -732,7 +734,8 @@ def register_database_api_routes(app):
732
734
  color = colors[i % len(colors)]
733
735
 
734
736
  datasets.append({
735
- "label": f"{game} - Lines Received",
737
+ "label": f"{game}",
738
+ "for": "Lines Received",
736
739
  "data": final_data[game]['lines'],
737
740
  "borderColor": color,
738
741
  "backgroundColor": f"{color}33", # Semi-transparent for fill
@@ -740,7 +743,8 @@ def register_database_api_routes(app):
740
743
  "tension": 0.1
741
744
  })
742
745
  datasets.append({
743
- "label": f"{game} - Characters Read",
746
+ "label": f"{game}",
747
+ "for": "Characters Read",
744
748
  "data": final_data[game]['chars'],
745
749
  "borderColor": color,
746
750
  "backgroundColor": f"{color}33",
@@ -780,4 +784,165 @@ def register_database_api_routes(app):
780
784
  "currentGameStats": current_game_stats,
781
785
  "allGamesStats": all_games_stats,
782
786
  "allLinesData": all_lines_data
783
- })
787
+ })
788
+
789
+ @app.route('/api/import-exstatic', methods=['POST'])
790
+ def api_import_exstatic():
791
+ """
792
+ Import ExStatic CSV data into GSM database.
793
+ Expected CSV format: uuid,given_identifier,name,line,time
794
+ """
795
+ try:
796
+ # Check if file is provided
797
+ if 'file' not in request.files:
798
+ return jsonify({'error': 'No file provided'}), 400
799
+
800
+ file = request.files['file']
801
+ if file.filename == '':
802
+ return jsonify({'error': 'No file selected'}), 400
803
+
804
+ # Validate file type
805
+ if not file.filename.lower().endswith('.csv'):
806
+ return jsonify({'error': 'File must be a CSV file'}), 400
807
+
808
+ # Read and parse CSV
809
+ try:
810
+ # Read file content as text with proper encoding handling
811
+ file_content = file.read().decode('utf-8-sig') # Handle BOM if present
812
+
813
+ # First, get the header line manually to avoid issues with multi-line content
814
+ lines = file_content.split('\n')
815
+ if len(lines) == 1 and not lines[0].strip():
816
+ return jsonify({'error': 'Empty CSV file'}), 400
817
+
818
+ header_line = lines[0].strip()
819
+ logger.info(f"Header line: {header_line}")
820
+
821
+ # Parse headers manually
822
+ header_reader = csv.reader([header_line])
823
+ try:
824
+ headers = next(header_reader)
825
+ headers = [h.strip() for h in headers] # Clean whitespace
826
+ logger.info(f"Parsed headers: {headers}")
827
+ except StopIteration:
828
+ return jsonify({'error': 'Could not parse CSV headers'}), 400
829
+
830
+ # Validate headers
831
+ expected_headers = {'uuid', 'given_identifier', 'name', 'line', 'time'}
832
+ actual_headers = set(headers)
833
+
834
+ if not expected_headers.issubset(actual_headers):
835
+ missing_headers = expected_headers - actual_headers
836
+ # Check if this looks like a stats CSV instead of lines CSV
837
+ if 'client' in actual_headers and 'chars_read' in actual_headers:
838
+ return jsonify({
839
+ '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'
840
+ }), 400
841
+ else:
842
+ return jsonify({
843
+ 'error': f'Invalid CSV format. Missing required columns: {", ".join(missing_headers)}. Expected format: uuid, given_identifier, name, line, time. Found headers: {", ".join(actual_headers)}'
844
+ }), 400
845
+
846
+ # Now parse the full CSV with proper handling for multi-line fields
847
+ file_io = io.StringIO(file_content)
848
+ csv_reader = csv.DictReader(file_io, quoting=csv.QUOTE_MINIMAL, skipinitialspace=True)
849
+
850
+ # Process CSV rows
851
+ imported_lines = []
852
+ games_set = set()
853
+ errors = []
854
+ seen_uuids = set() # Track UUIDs within this import batch
855
+
856
+ for row_num, row in enumerate(csv_reader):
857
+ try:
858
+ # Extract and validate required fields
859
+ uuid = row.get('uuid', '').strip()
860
+ name = row.get('name', '').strip()
861
+ line = row.get('line', '').strip()
862
+ time_str = row.get('time', '').strip()
863
+
864
+ # Validate required fields
865
+ if not uuid:
866
+ errors.append(f"Row {row_num}: Missing UUID")
867
+ continue
868
+ if not name:
869
+ errors.append(f"Row {row_num}: Missing name")
870
+ continue
871
+ if not line:
872
+ errors.append(f"Row {row_num}: Missing line text")
873
+ continue
874
+ if not time_str:
875
+ errors.append(f"Row {row_num}: Missing time")
876
+ continue
877
+
878
+ # Check for duplicates within this import batch
879
+ if uuid in seen_uuids:
880
+ logger.info(f"Skipping duplicate UUID within import batch: {uuid}")
881
+ continue
882
+ seen_uuids.add(uuid)
883
+
884
+ # Convert time to timestamp
885
+ try:
886
+ timestamp = float(time_str)
887
+ except ValueError:
888
+ errors.append(f"Row {row_num}: Invalid time format: {time_str}")
889
+ continue
890
+
891
+ # Clean up line text (remove extra whitespace and newlines)
892
+ line_text = line.strip()
893
+
894
+ # Check if this UUID already exists in database
895
+ existing_line = GameLinesTable.get(uuid)
896
+ if existing_line:
897
+ logger.info(f"Skipping duplicate UUID already in database: {uuid}")
898
+ continue
899
+
900
+ # Create GameLinesTable entry
901
+ game_line = GameLinesTable(
902
+ id=uuid,
903
+ game_name=name,
904
+ line_text=line_text,
905
+ timestamp=timestamp
906
+ )
907
+
908
+ imported_lines.append(game_line)
909
+ games_set.add(name)
910
+
911
+ except Exception as e:
912
+ errors.append(f"Row {row_num}: Error processing row - {str(e)}")
913
+ continue
914
+
915
+ # Import lines into database
916
+ imported_count = 0
917
+ for game_line in imported_lines:
918
+ try:
919
+ game_line.add()
920
+ imported_count += 1
921
+ except Exception as e:
922
+ logger.error(f"Failed to import line {game_line.id}: {e}")
923
+ errors.append(f"Failed to import line {game_line.id}: {str(e)}")
924
+
925
+ # Prepare response
926
+ response_data = {
927
+ 'message': f'Successfully imported {imported_count} lines from {len(games_set)} games',
928
+ 'imported_count': imported_count,
929
+ 'games_count': len(games_set),
930
+ 'games': list(games_set)
931
+ }
932
+
933
+ if errors:
934
+ response_data['warnings'] = errors
935
+ response_data['warning_count'] = len(errors)
936
+
937
+ logger.info(f"ExStatic import completed: {imported_count} lines from {len(games_set)} games")
938
+
939
+ return jsonify(response_data), 200
940
+
941
+ except csv.Error as e:
942
+ return jsonify({'error': f'CSV parsing error: {str(e)}'}), 400
943
+ except UnicodeDecodeError:
944
+ return jsonify({'error': 'File encoding error. Please ensure the CSV is UTF-8 encoded.'}), 400
945
+
946
+ except Exception as e:
947
+ logger.error(f"Error in ExStatic import: {e}")
948
+ return jsonify({'error': f'Import failed: {str(e)}'}), 500
@@ -725,12 +725,12 @@ document.addEventListener('DOMContentLoaded', function () {
725
725
  // Filter datasets for each chart
726
726
  const linesData = {
727
727
  labels: data.labels,
728
- datasets: data.datasets.filter(d => d.label.includes('Lines Received'))
728
+ datasets: data.datasets.filter(d => d.for === "Lines Received")
729
729
  };
730
730
 
731
731
  const charsData = {
732
732
  labels: data.labels,
733
- datasets: data.datasets.filter(d => d.label.includes('Characters Read'))
733
+ datasets: data.datasets.filter(d => d.for === 'Characters Read')
734
734
  };
735
735
 
736
736
  // Remove the 'hidden' property so they appear on their own charts
@@ -1424,4 +1424,143 @@ document.addEventListener('DOMContentLoaded', function () {
1424
1424
  if (document.getElementById('gamesTableBody')) {
1425
1425
  new GameDeletionManager();
1426
1426
  }
1427
+
1428
+ // ExStatic Import Functionality
1429
+ const exstaticFileInput = document.getElementById('exstaticFile');
1430
+ const importExstaticBtn = document.getElementById('importExstaticBtn');
1431
+ const importProgress = document.getElementById('importProgress');
1432
+ const importProgressBar = document.getElementById('importProgressBar');
1433
+ const importProgressText = document.getElementById('importProgressText');
1434
+ const importStatus = document.getElementById('importStatus');
1435
+
1436
+ if (exstaticFileInput && importExstaticBtn) {
1437
+ // Enable/disable import button based on file selection
1438
+ exstaticFileInput.addEventListener('change', function(e) {
1439
+ const file = e.target.files[0];
1440
+ if (file && file.type === 'text/csv' && file.name.toLowerCase().endsWith('.csv')) {
1441
+ importExstaticBtn.disabled = false;
1442
+ importExstaticBtn.style.background = '#2980b9';
1443
+ importExstaticBtn.style.cursor = 'pointer';
1444
+ showImportStatus('', 'info', false);
1445
+ } else {
1446
+ importExstaticBtn.disabled = true;
1447
+ importExstaticBtn.style.background = '#666';
1448
+ importExstaticBtn.style.cursor = 'not-allowed';
1449
+ if (file) {
1450
+ showImportStatus('Please select a valid CSV file.', 'error', true);
1451
+ }
1452
+ }
1453
+ });
1454
+
1455
+ // Handle import button click
1456
+ importExstaticBtn.addEventListener('click', function() {
1457
+ const file = exstaticFileInput.files[0];
1458
+ if (!file) {
1459
+ showImportStatus('Please select a CSV file first.', 'error', true);
1460
+ return;
1461
+ }
1462
+
1463
+ importExstaticData(file);
1464
+ });
1465
+ }
1466
+
1467
+ function showImportStatus(message, type, show) {
1468
+ if (!importStatus) return;
1469
+
1470
+ if (show && message) {
1471
+ importStatus.textContent = message;
1472
+ importStatus.style.display = 'block';
1473
+
1474
+ // Set appropriate styling based on type
1475
+ if (type === 'error') {
1476
+ importStatus.style.background = 'var(--danger-color)';
1477
+ importStatus.style.color = 'white';
1478
+ } else if (type === 'success') {
1479
+ importStatus.style.background = 'var(--success-color)';
1480
+ importStatus.style.color = 'white';
1481
+ } else if (type === 'info') {
1482
+ importStatus.style.background = 'var(--primary-color)';
1483
+ importStatus.style.color = 'white';
1484
+ } else {
1485
+ importStatus.style.background = 'var(--bg-tertiary)';
1486
+ importStatus.style.color = 'var(--text-primary)';
1487
+ }
1488
+ } else {
1489
+ importStatus.style.display = 'none';
1490
+ }
1491
+ }
1492
+
1493
+ function showImportProgress(show, percentage = 0) {
1494
+ if (!importProgress || !importProgressBar || !importProgressText) return;
1495
+
1496
+ if (show) {
1497
+ importProgress.style.display = 'block';
1498
+ importProgressBar.style.width = percentage + '%';
1499
+ importProgressText.textContent = Math.round(percentage) + '%';
1500
+ } else {
1501
+ importProgress.style.display = 'none';
1502
+ }
1503
+ }
1504
+
1505
+ async function importExstaticData(file) {
1506
+ try {
1507
+ // Disable import button and show progress
1508
+ importExstaticBtn.disabled = true;
1509
+ showImportProgress(true, 0);
1510
+ showImportStatus('Preparing import...', 'info', true);
1511
+
1512
+ // Create FormData and append the file
1513
+ const formData = new FormData();
1514
+ formData.append('file', file);
1515
+
1516
+ // Show upload progress
1517
+ showImportProgress(true, 25);
1518
+ showImportStatus('Uploading file...', 'info', true);
1519
+
1520
+ // Send file to backend
1521
+ const response = await fetch('/api/import-exstatic', {
1522
+ method: 'POST',
1523
+ body: formData
1524
+ });
1525
+
1526
+ showImportProgress(true, 75);
1527
+ showImportStatus('Processing data...', 'info', true);
1528
+
1529
+ const result = await response.json();
1530
+
1531
+ showImportProgress(true, 100);
1532
+
1533
+ if (response.ok) {
1534
+ // Success
1535
+ const message = `Successfully imported ${result.imported_count || 0} lines from ${result.games_count || 0} games.`;
1536
+ showImportStatus(message, 'success', true);
1537
+
1538
+ // Reset file input and button
1539
+ exstaticFileInput.value = '';
1540
+ importExstaticBtn.disabled = true;
1541
+
1542
+ // Hide progress after a delay
1543
+ setTimeout(() => {
1544
+ showImportProgress(false);
1545
+ // Optionally refresh the page to show new data
1546
+ if (result.imported_count > 0) {
1547
+ setTimeout(() => {
1548
+ window.location.reload();
1549
+ }, 2000);
1550
+ }
1551
+ }, 1500);
1552
+ } else {
1553
+ // Error
1554
+ showImportStatus(result.error || 'Import failed. Please try again.', 'error', true);
1555
+ showImportProgress(false);
1556
+ }
1557
+ } catch (error) {
1558
+ console.error('Import error:', error);
1559
+ showImportStatus('Import failed due to network error. Please try again.', 'error', true);
1560
+ showImportProgress(false);
1561
+ } finally {
1562
+ // Re-enable import button only if a file is still selected
1563
+ importExstaticBtn.disabled = !(exstaticFileInput && exstaticFileInput.files && exstaticFileInput.files.length > 0);
1564
+ }
1565
+ }
1427
1566
  });
@@ -313,6 +313,39 @@
313
313
  Minimum hours of reading activity required to maintain streak (0.01-24 hours)
314
314
  </small>
315
315
  </div>
316
+
317
+ <!-- Import ExStatic Lines Section -->
318
+ <div style="margin-bottom: 20px; padding-top: 20px; border-top: 1px solid var(--border-color);">
319
+ <label style="display: block; font-weight: 600; margin-bottom: 8px; color: var(--text-primary);">
320
+ Import ExStatic Lines
321
+ </label>
322
+ <div style="margin-bottom: 10px;">
323
+ <input
324
+ type="file"
325
+ id="exstaticFile"
326
+ accept=".csv"
327
+ style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 5px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 14px;"
328
+ >
329
+ <small style="color: var(--text-tertiary); font-size: 12px; margin-top: 4px; display: block;">
330
+ Select an ExStatic CSV file to import reading data into GSM
331
+ </small>
332
+ </div>
333
+ <button
334
+ type="button"
335
+ id="importExstaticBtn"
336
+ style="width: 100%; padding: 10px; background: #666; color: white; border: none; border-radius: 5px; font-size: 14px; cursor: not-allowed; margin-bottom: 10px;"
337
+ disabled
338
+ >
339
+ Import ExStatic Lines
340
+ </button>
341
+ <div id="importProgress" style="display: none; margin-bottom: 10px;">
342
+ <div style="background: var(--bg-tertiary); border-radius: 5px; overflow: hidden; height: 20px; position: relative;">
343
+ <div id="importProgressBar" style="background: var(--primary-color); height: 100%; width: 0%; transition: width 0.3s ease;"></div>
344
+ <span id="importProgressText" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; color: var(--text-primary);">0%</span>
345
+ </div>
346
+ </div>
347
+ <div id="importStatus" style="display: none; padding: 10px; border-radius: 5px; font-size: 14px;"></div>
348
+ </div>
316
349
  </form>
317
350
 
318
351
  <div id="settingsError" style="display: none; background: var(--danger-color); color: white; padding: 10px; border-radius: 5px; margin-bottom: 15px; font-size: 14px;"></div>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GameSentenceMiner
3
- Version: 2.16.2
3
+ Version: 2.16.3
4
4
  Summary: A tool for mining sentences from games. Update: Overlay?
5
5
  Author-email: Beangate <bpwhelan95@gmail.com>
6
6
  License: MIT License
@@ -1,10 +1,10 @@
1
1
  GameSentenceMiner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  GameSentenceMiner/anki.py,sha256=rm9JuRP-1Eba2wcVQ2PZUMB5P9UMEZ99Fh371K0Qfhk,26319
3
- GameSentenceMiner/config_gui.py,sha256=i79PrY2pP8_VKvIL7uoDv5cgHvCCQBIe0mS_YnX2AVg,140792
3
+ GameSentenceMiner/config_gui.py,sha256=xpv2g7LaKOmv98nHTkOtYpsl3dMFMwmHbp5Ioms2v54,141667
4
4
  GameSentenceMiner/gametext.py,sha256=fgBgLchezpauWELE9Y5G3kVCLfAneD0X4lJFoI3FYbs,10351
5
5
  GameSentenceMiner/gsm.py,sha256=CcXGZI8K7cYB9FJfCRaOujlWDk08sQGQXhTiMewMhFg,31889
6
6
  GameSentenceMiner/obs.py,sha256=EyAYhaLvMjoeC-3j7fuvkqZN5logFFanPfb8Wn1C6m0,27296
7
- GameSentenceMiner/vad.py,sha256=N-urlVOT6ayk4BFuIeZhxK1VxLD4Flxu-IStuy8FAVc,20190
7
+ GameSentenceMiner/vad.py,sha256=ZC1U31C0qHDtWEmk1iYVcNyhrKfKxC1aOZpbP1pv9Oo,20145
8
8
  GameSentenceMiner/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  GameSentenceMiner/ai/ai_prompting.py,sha256=41xdBzE88Jlt12A0D-T_cMfLO5j6MSxfniOptpwNZm0,24068
10
10
  GameSentenceMiner/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -15,9 +15,9 @@ GameSentenceMiner/assets/icon32.png,sha256=Kww0hU_qke9_22wBuO_Nq0Dv2SfnOLwMhCyGg
15
15
  GameSentenceMiner/assets/icon512.png,sha256=HxUj2GHjyQsk8NV433256UxU9phPhtjCY-YB_7W4sqs,192487
16
16
  GameSentenceMiner/assets/icon64.png,sha256=N8xgdZXvhqVQP9QUK3wX5iqxX9LxHljD7c-Bmgim6tM,9301
17
17
  GameSentenceMiner/assets/pickaxe.png,sha256=VfIGyXyIZdzEnVcc4PmG3wszPMO1W4KCT7Q_nFK6eSE,1403829
18
- GameSentenceMiner/locales/en_us.json,sha256=4lCV34FnDOe0c02qHlHnfujQedmqHSL-feN3lYCCCfs,26744
19
- GameSentenceMiner/locales/ja_jp.json,sha256=LNLo2qIugMcDGiPbSo018zVAU8K_HG8Q4zvIcsHUzTA,28517
20
- GameSentenceMiner/locales/zh_cn.json,sha256=lZYB3HAcxhVCSVWcnvuepuCvn6_Y2mvd0-SKJEYx_ko,24829
18
+ GameSentenceMiner/locales/en_us.json,sha256=Wc2sOs5JoWB3_oqA5yVmvslPa60Ud-lpGm5g4Mkp8QE,26969
19
+ GameSentenceMiner/locales/ja_jp.json,sha256=q923lIhVrofKM9ff1puwagGLvIGoV6LRvD_smoAnZn8,28809
20
+ GameSentenceMiner/locales/zh_cn.json,sha256=8f94Jy66ox7XCLYIbdbBjLSBgSBA3fV3a1meTxutCOw,25062
21
21
  GameSentenceMiner/ocr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  GameSentenceMiner/ocr/gsm_ocr_config.py,sha256=Ov04c-nKzh3sADxO-5JyZWVe4DlrHM9edM9tc7-97Jo,5970
23
23
  GameSentenceMiner/ocr/ocrconfig.py,sha256=_tY8mjnzHMJrLS8E5pHqYXZjMuLoGKYgJwdhYgN-ny4,6466
@@ -37,11 +37,11 @@ GameSentenceMiner/tools/furigana_filter_preview.py,sha256=BXv7FChPEJW_VeG5XYt6su
37
37
  GameSentenceMiner/tools/ss_selector.py,sha256=cbjMxiKOCuOfbRvLR_PCRlykBrGtm1LXd6u5czPqkmc,4793
38
38
  GameSentenceMiner/tools/window_transparency.py,sha256=GtbxbmZg0-UYPXhfHff-7IKZyY2DKe4B9GdyovfmpeM,8166
39
39
  GameSentenceMiner/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
- GameSentenceMiner/util/configuration.py,sha256=0S8rf_TTy7qbUZc7PLUXEZVScx27tV0cDZR_GcM1QKk,40412
41
- GameSentenceMiner/util/db.py,sha256=2bO0rD4i8A1hhsRBER-wgZy9IK17ibRbI8DHxdKvYsI,16598
40
+ GameSentenceMiner/util/configuration.py,sha256=XANq-FJ_9tNFQSQvozHnn7fGnRiiKH82_aPHwyUfd8g,40528
41
+ GameSentenceMiner/util/db.py,sha256=NjbzSg4vQTfjm8wb5GUnPVvFR4RsnwV65X-IXIG3yRQ,18013
42
42
  GameSentenceMiner/util/electron_config.py,sha256=KfeJToeFFVw0IR5MKa-gBzpzaGrU-lyJbR9z-sDEHYU,8767
43
43
  GameSentenceMiner/util/ffmpeg.py,sha256=jA-cFtCmdCWrUSPpdtFSLr-GSoqs4qNUzW20v4HPHf0,28715
44
- GameSentenceMiner/util/get_overlay_coords.py,sha256=P5tI7H0cnveGs33aQdvJGy9DV6aIAGh8K8Al1XjNPzw,15114
44
+ GameSentenceMiner/util/get_overlay_coords.py,sha256=TEMxhrBE8302WQj2K5q7SLKUhgNH_J-RjHpkUTQJdcU,17577
45
45
  GameSentenceMiner/util/gsm_utils.py,sha256=Piwv88Q9av2LBeN7M6QDi0Mp0_R2lNbkcI6ekK5hd2o,11851
46
46
  GameSentenceMiner/util/model.py,sha256=R-_RYTYLSDNgBoVTPuPBcIHeOznIqi_vBzQ7VQ20WYk,6727
47
47
  GameSentenceMiner/util/notification.py,sha256=YBhf_mSo_i3cjBz-pmeTPx3wchKiG9BK2VBdZSa2prQ,4597
@@ -56,7 +56,7 @@ GameSentenceMiner/util/downloader/oneocr_dl.py,sha256=l3s9Z-x1b57GX048o5h-MVv0UT
56
56
  GameSentenceMiner/util/win10toast/__init__.py,sha256=6TL2w6rzNmpJEp6_v2cAJP_7ExA3UsKzwdM08pNcVfE,5341
57
57
  GameSentenceMiner/util/win10toast/__main__.py,sha256=5MYnBcFj8y_6Dyc1kiPd0_FsUuh4yl1cv5wsleU6V4w,668
58
58
  GameSentenceMiner/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
- GameSentenceMiner/web/database_api.py,sha256=kcyTWPuw_qtrK5qBzCFTIP0tqIvPmL-NEtiRL9BNbe8,35079
59
+ GameSentenceMiner/web/database_api.py,sha256=uSEgneO9Tq7FxmBmXSXi5Y1_Z37YL0jVb97SpK_qFDo,43088
60
60
  GameSentenceMiner/web/events.py,sha256=6Vyz5c9MdpMIa7Zqljqhap2XFQnAVYJ0CdQV64TSZsA,5119
61
61
  GameSentenceMiner/web/service.py,sha256=YZchmScTn7AX_GkwV1ULEK6qjdOnJcpc3qfMwDf7cUE,5363
62
62
  GameSentenceMiner/web/stats.py,sha256=daSSxWlumAyqVVtX10qHESF-tZYwCcFMp8qZA5AE0nI,22066
@@ -80,19 +80,19 @@ GameSentenceMiner/web/static/js/database.js,sha256=-SjMmhXzU8a3QNGrwGtJCu55ZXXfk
80
80
  GameSentenceMiner/web/static/js/kanji-grid.js,sha256=rUa8_TGFm4Z8CtURoAlZjCN032PLe0YmHvN52S4_sE0,7181
81
81
  GameSentenceMiner/web/static/js/search.js,sha256=nXvHZelCQQlTWJwaF_j3RnU5v_JFVIDQSEtnauDy8lg,9484
82
82
  GameSentenceMiner/web/static/js/shared.js,sha256=HdQWRToX9U09h0x35K00RrHFw6mBkCPRf7JoLwN0l0c,18410
83
- GameSentenceMiner/web/static/js/stats.js,sha256=_B5QQWv1Wxx8fPyX4VDmTxs31Ds1EmX0XERt2uJj_9M,60882
83
+ GameSentenceMiner/web/static/js/stats.js,sha256=EOyM6ZyTQu1IubBQwjnB4mE9vVH7LJwsiUl5Q6LiRCo,66673
84
84
  GameSentenceMiner/web/templates/anki_stats.html,sha256=FdIMl-kY0-3as9Wn0i-wKIIkl1xXn4nWMbWPypN_1T0,10955
85
85
  GameSentenceMiner/web/templates/database.html,sha256=Gd54rgZmfcV7ufoJ69COeMncs5Q5u-rSJcsIvROVCEo,13732
86
86
  GameSentenceMiner/web/templates/index.html,sha256=7ChQ1j602MOiYU95wXAKP_Ezsh_JgdlGz2uXIzS2j0g,227894
87
87
  GameSentenceMiner/web/templates/search.html,sha256=OGfzhNc4QP_kmJdCnEnLm5pzgW6XKERoin0oQZxvg5s,3851
88
- GameSentenceMiner/web/templates/stats.html,sha256=ia-o2rN3nDo3mFCVFtJKBFZI1LfkQrMIb26IJ6llmws,16976
88
+ GameSentenceMiner/web/templates/stats.html,sha256=sV1nea99Z6oo5ZtVChqAo3dttoU38FaUEpW9SXEv-Bs,19395
89
89
  GameSentenceMiner/web/templates/utility.html,sha256=KtqnZUMAYs5XsEdC9Tlsd40NKAVic0mu6sh-ReMDJpU,16940
90
90
  GameSentenceMiner/web/templates/components/navigation.html,sha256=6y9PvM3nh8LY6JWrZb6zVOm0vqkBLDc6d3gB9X5lT_w,1055
91
91
  GameSentenceMiner/web/templates/components/theme-styles.html,sha256=hiq3zdJljpRjQO1iUA7gfFKwXebltG-IWW-gnKS4GHA,3439
92
92
  GameSentenceMiner/wip/__init___.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
93
- gamesentenceminer-2.16.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
- gamesentenceminer-2.16.2.dist-info/METADATA,sha256=ErUIV8oqKx28arom2ST3br8r9TsV2As-5_mRQkJygRQ,7348
95
- gamesentenceminer-2.16.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
96
- gamesentenceminer-2.16.2.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
97
- gamesentenceminer-2.16.2.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
98
- gamesentenceminer-2.16.2.dist-info/RECORD,,
93
+ gamesentenceminer-2.16.3.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
+ gamesentenceminer-2.16.3.dist-info/METADATA,sha256=lGsz20X1lkBNWh1Q0OknNcqbegQiviQnXe2PTSRsHP4,7348
95
+ gamesentenceminer-2.16.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
96
+ gamesentenceminer-2.16.3.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
97
+ gamesentenceminer-2.16.3.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
98
+ gamesentenceminer-2.16.3.dist-info/RECORD,,