GameSentenceMiner 2.7.17__py3-none-any.whl → 2.8.1__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.
Files changed (34) hide show
  1. GameSentenceMiner/anki.py +7 -8
  2. GameSentenceMiner/config_gui.py +19 -3
  3. GameSentenceMiner/configuration.py +8 -1
  4. GameSentenceMiner/downloader/oneocr_dl.py +243 -0
  5. GameSentenceMiner/ffmpeg.py +1 -3
  6. GameSentenceMiner/gametext.py +16 -155
  7. GameSentenceMiner/gsm.py +28 -29
  8. GameSentenceMiner/obs.py +0 -3
  9. GameSentenceMiner/ocr/ocrconfig.py +0 -1
  10. GameSentenceMiner/ocr/owocr_area_selector.py +0 -1
  11. GameSentenceMiner/ocr/owocr_helper.py +25 -26
  12. GameSentenceMiner/text_log.py +186 -0
  13. GameSentenceMiner/util.py +60 -3
  14. GameSentenceMiner/web/__init__.py +0 -0
  15. GameSentenceMiner/web/static/__init__.py +0 -0
  16. GameSentenceMiner/web/static/apple-touch-icon.png +0 -0
  17. GameSentenceMiner/web/static/favicon-96x96.png +0 -0
  18. GameSentenceMiner/web/static/favicon.ico +0 -0
  19. GameSentenceMiner/web/static/favicon.svg +3 -0
  20. GameSentenceMiner/web/static/site.webmanifest +21 -0
  21. GameSentenceMiner/web/static/style.css +292 -0
  22. GameSentenceMiner/web/static/text_replacements.html +238 -0
  23. GameSentenceMiner/web/static/utility.html +313 -0
  24. GameSentenceMiner/web/static/web-app-manifest-192x192.png +0 -0
  25. GameSentenceMiner/web/static/web-app-manifest-512x512.png +0 -0
  26. GameSentenceMiner/web/texthooking_page.py +234 -0
  27. {gamesentenceminer-2.7.17.dist-info → gamesentenceminer-2.8.1.dist-info}/METADATA +2 -1
  28. gamesentenceminer-2.8.1.dist-info/RECORD +58 -0
  29. {gamesentenceminer-2.7.17.dist-info → gamesentenceminer-2.8.1.dist-info}/WHEEL +1 -1
  30. GameSentenceMiner/utility_gui.py +0 -204
  31. gamesentenceminer-2.7.17.dist-info/RECORD +0 -44
  32. {gamesentenceminer-2.7.17.dist-info → gamesentenceminer-2.8.1.dist-info}/entry_points.txt +0 -0
  33. {gamesentenceminer-2.7.17.dist-info → gamesentenceminer-2.8.1.dist-info}/licenses/LICENSE +0 -0
  34. {gamesentenceminer-2.7.17.dist-info → gamesentenceminer-2.8.1.dist-info}/top_level.txt +0 -0
GameSentenceMiner/gsm.py CHANGED
@@ -10,7 +10,6 @@ from pystray import Icon, Menu, MenuItem
10
10
  from watchdog.events import FileSystemEventHandler
11
11
  from watchdog.observers import Observer
12
12
 
13
- import time
14
13
 
15
14
  from GameSentenceMiner import anki
16
15
  from GameSentenceMiner import config_gui
@@ -27,10 +26,11 @@ from GameSentenceMiner.communication.websocket import connect_websocket, registe
27
26
  from GameSentenceMiner.configuration import *
28
27
  from GameSentenceMiner.downloader.download_tools import download_obs_if_needed, download_ffmpeg_if_needed
29
28
  from GameSentenceMiner.ffmpeg import get_audio_and_trim, get_video_timings
30
- from GameSentenceMiner.gametext import get_text_event, get_mined_line, GameLine
31
29
  from GameSentenceMiner.obs import check_obs_folder_is_correct
30
+ from GameSentenceMiner.text_log import GameLine, get_text_event, get_mined_line, get_all_lines
32
31
  from GameSentenceMiner.util import *
33
- from GameSentenceMiner.utility_gui import init_utility_window, get_utility_window
32
+ from GameSentenceMiner.web import texthooking_page
33
+ from GameSentenceMiner.web.texthooking_page import start_web_server
34
34
 
35
35
  if is_windows():
36
36
  import win32api
@@ -57,9 +57,9 @@ class VideoToAudioHandler(FileSystemEventHandler):
57
57
  @staticmethod
58
58
  def convert_to_audio(video_path):
59
59
  try:
60
- if get_utility_window().line_for_audio:
61
- line: GameLine = get_utility_window().line_for_audio
62
- get_utility_window().line_for_audio = None
60
+ if texthooking_page.event_manager.line_for_audio:
61
+ line: GameLine = texthooking_page.event_manager.line_for_audio
62
+ texthooking_page.event_manager.line_for_audio = None
63
63
  if get_config().advanced.audio_player_path:
64
64
  audio = VideoToAudioHandler.get_audio(line, line.next.time if line.next else None, video_path, temporary=True)
65
65
  play_audio_in_external(audio)
@@ -67,9 +67,9 @@ class VideoToAudioHandler(FileSystemEventHandler):
67
67
  elif get_config().advanced.video_player_path:
68
68
  play_video_in_external(line, video_path)
69
69
  return
70
- if get_utility_window().line_for_screenshot:
71
- line: GameLine = get_utility_window().line_for_screenshot
72
- get_utility_window().line_for_screenshot = None
70
+ if texthooking_page.event_manager.line_for_screenshot:
71
+ line: GameLine = texthooking_page.event_manager.line_for_screenshot
72
+ texthooking_page.event_manager.line_for_screenshot = None
73
73
  screenshot = ffmpeg.get_screenshot_for_line(video_path, line)
74
74
  os.startfile(screenshot)
75
75
  os.remove(video_path)
@@ -109,18 +109,18 @@ class VideoToAudioHandler(FileSystemEventHandler):
109
109
  if mined_line.next:
110
110
  line_cutoff = mined_line.next.time
111
111
 
112
- if get_utility_window().lines_selected():
113
- lines = get_utility_window().get_selected_lines()
114
- start_line = lines[0]
115
- mined_line = get_mined_line(last_note, lines)
116
- line_cutoff = get_utility_window().get_next_line_timing()
112
+ selected_lines = []
113
+ if texthooking_page.are_lines_selected():
114
+ selected_lines = texthooking_page.get_selected_lines()
115
+ start_line = selected_lines[0]
116
+ mined_line = get_mined_line(last_note, selected_lines)
117
+ line_cutoff = selected_lines[-1].get_next_time()
117
118
 
118
119
  if last_note:
119
120
  logger.debug(last_note.to_json())
120
- selected_lines = get_utility_window().get_selected_lines()
121
121
  note = anki.get_initial_card_info(last_note, selected_lines)
122
122
  tango = last_note.get_field(get_config().anki.word_field) if last_note else ''
123
- get_utility_window().reset_checkboxes()
123
+ texthooking_page.reset_checked_lines()
124
124
 
125
125
  if get_config().anki.sentence_audio_field and get_config().audio.enabled:
126
126
  logger.debug("Attempting to get audio from video")
@@ -268,10 +268,10 @@ def register_hotkeys():
268
268
  keyboard.add_hotkey(get_config().hotkeys.reset_line, gametext.reset_line_hotkey_pressed)
269
269
  if get_config().hotkeys.take_screenshot:
270
270
  keyboard.add_hotkey(get_config().hotkeys.take_screenshot, get_screenshot)
271
- if get_config().hotkeys.open_utility:
272
- keyboard.add_hotkey(get_config().hotkeys.open_utility, open_multimine)
273
271
  if get_config().hotkeys.play_latest_audio:
274
272
  keyboard.add_hotkey(get_config().hotkeys.play_latest_audio, play_most_recent_audio)
273
+ if get_config().hotkeys.open_utility:
274
+ keyboard.add_hotkey(get_config().hotkeys.open_utility, texthooking_page.open_texthooker)
275
275
 
276
276
 
277
277
  def get_screenshot():
@@ -322,13 +322,10 @@ def open_settings():
322
322
  settings_window.show()
323
323
 
324
324
 
325
- def open_multimine():
326
- obs.update_current_game()
327
- get_utility_window().show()
328
-
329
325
  def play_most_recent_audio():
330
- if get_config().advanced.audio_player_path or get_config().advanced.video_player_path and len(gametext.line_history.values) > 0:
331
- get_utility_window().line_for_audio = gametext.line_history.values[-1]
326
+ if get_config().advanced.audio_player_path or get_config().advanced.video_player_path and len(
327
+ get_all_lines()) > 0:
328
+ texthooking_page.event_manager.line_for_audio = get_all_lines()[-1]
332
329
  obs.save_replay_buffer()
333
330
  else:
334
331
  logger.error("Feature Disabled. No audio or video player path set in config!")
@@ -368,6 +365,10 @@ def play_pause(icon, item):
368
365
  update_icon()
369
366
 
370
367
 
368
+ def open_multimine(icon, item):
369
+ texthooking_page.open_texthooker()
370
+
371
+
371
372
  def update_icon():
372
373
  global menu, icon
373
374
  # Recreate the menu with the updated button text
@@ -416,7 +417,7 @@ def run_tray():
416
417
 
417
418
  menu = Menu(
418
419
  MenuItem("Open Settings", open_settings),
419
- MenuItem("Open Multi-Mine GUI", open_multimine),
420
+ MenuItem("Open Texthooker", texthooking_page.open_texthooker),
420
421
  MenuItem("Open Log", open_log),
421
422
  MenuItem("Toggle Replay Buffer", play_pause),
422
423
  MenuItem("Restart OBS", restart_obs),
@@ -554,6 +555,7 @@ def post_init():
554
555
  whisper_helper.initialize_whisper_model()
555
556
  if get_config().vad.is_silero():
556
557
  from GameSentenceMiner.vad import silero_trim
558
+ start_web_server()
557
559
 
558
560
  util.run_new_thread(do_post_init)
559
561
 
@@ -576,7 +578,6 @@ def main(reloading=False):
576
578
  logger.info("Script started.")
577
579
  root = ttk.Window(themename='darkly')
578
580
  settings_window = config_gui.ConfigApp(root)
579
- init_utility_window(root)
580
581
  initialize(reloading)
581
582
  initialize_async()
582
583
  observer = Observer()
@@ -594,9 +595,7 @@ def main(reloading=False):
594
595
  try:
595
596
  # if get_config().general.open_config_on_startup:
596
597
  # root.after(0, settings_window.show)
597
- if get_config().general.open_multimine_on_startup:
598
- root.after(0, get_utility_window().show)
599
- root.after(0, post_init)
598
+ root.after(50, post_init)
600
599
  settings_window.add_save_hook(update_icon)
601
600
  settings_window.on_exit = exit_program
602
601
  root.mainloop()
GameSentenceMiner/obs.py CHANGED
@@ -1,10 +1,7 @@
1
- import logging
2
1
  import os.path
3
2
  import subprocess
4
- import tempfile
5
3
  import time
6
4
 
7
- import psutil
8
5
  from obswebsocket import obsws, requests
9
6
 
10
7
  from GameSentenceMiner import util, configuration
@@ -1,6 +1,5 @@
1
1
  import configparser
2
2
  import os
3
- import re
4
3
 
5
4
  class OCRConfig:
6
5
  def __init__(self, config_file=os.path.expanduser("~/.config/owocr_config.ini")):
@@ -7,7 +7,6 @@ import mss
7
7
  from PIL import Image, ImageTk, ImageDraw
8
8
 
9
9
  from GameSentenceMiner import obs # Import your actual obs module
10
- from GameSentenceMiner.ocr.owocr_helper import get_ocr_config
11
10
  from GameSentenceMiner.util import sanitize_filename # Import your actual util module
12
11
 
13
12
  try:
@@ -1,9 +1,9 @@
1
1
  import asyncio
2
- import difflib
3
2
  import json
4
3
  import logging
5
4
  import os
6
5
  import queue
6
+ import re
7
7
  import threading
8
8
  import time
9
9
  from datetime import datetime
@@ -14,17 +14,14 @@ from tkinter import messagebox
14
14
  import mss
15
15
  import websockets
16
16
  from rapidfuzz import fuzz
17
- from PIL import Image, ImageDraw
18
17
 
19
18
  from GameSentenceMiner import obs, util
20
- from GameSentenceMiner.configuration import get_config, get_app_directory
19
+ from GameSentenceMiner.configuration import get_config, get_app_directory, get_temporary_directory
21
20
  from GameSentenceMiner.electron_config import get_ocr_scan_rate, get_requires_open_window
22
21
  from GameSentenceMiner.ocr.gsm_ocr_config import OCRConfig, Rectangle
23
22
  from GameSentenceMiner.owocr.owocr import screen_coordinate_picker, run
24
23
  from GameSentenceMiner.owocr.owocr.run import TextFiltering
25
-
26
- from dataclasses import dataclass
27
- from typing import List, Optional
24
+ from GameSentenceMiner.util import do_text_replacements, OCR_REPLACEMENTS_FILE
28
25
 
29
26
  CONFIG_FILE = Path("ocr_config.json")
30
27
  DEFAULT_IMAGE_PATH = r"C:\Users\Beangate\Pictures\msedge_acbl8GL7Ax.jpg" # CHANGE THIS
@@ -80,6 +77,7 @@ def get_ocr_config() -> OCRConfig:
80
77
  """Loads and updates screen capture areas from the corresponding JSON file."""
81
78
  app_dir = Path.home() / "AppData" / "Roaming" / "GameSentenceMiner"
82
79
  ocr_config_dir = app_dir / "ocr_config"
80
+ os.makedirs(ocr_config_dir, exist_ok=True)
83
81
  obs.connect_to_obs()
84
82
  scene = util.sanitize_filename(obs.get_current_scene())
85
83
  config_path = ocr_config_dir / f"{scene}.json"
@@ -217,63 +215,62 @@ def do_second_ocr(ocr1_text, rectangle_index, time, img):
217
215
  if fuzz.ratio(previous_ocr2_text, text) >= 80:
218
216
  logger.info("Seems like the same text from previous ocr2 result, not sending")
219
217
  return
220
- img.save(os.path.join(get_app_directory(), "temp", "last_successful_ocr.png"))
218
+ img.save(os.path.join(get_temporary_directory(), "last_successful_ocr.png"))
221
219
  last_ocr2_results[rectangle_index] = text
222
- if get_config().advanced.ocr_sends_to_clipboard:
223
- import pyperclip
224
- pyperclip.copy(text)
225
- websocket_server_thread.send_text(text, time)
220
+ send_result(text, time)
226
221
  except json.JSONDecodeError:
227
222
  print("Invalid JSON received.")
228
223
  except Exception as e:
229
224
  logger.exception(e)
230
225
  print(f"Error processing message: {e}")
231
226
 
227
+ def send_result(text, time):
228
+ if text:
229
+ text = do_text_replacements(text, OCR_REPLACEMENTS_FILE)
230
+ if get_config().advanced.ocr_sends_to_clipboard:
231
+ import pyperclip
232
+ pyperclip.copy(text)
233
+ websocket_server_thread.send_text(text, time)
234
+
232
235
 
233
236
  last_oneocr_results_to_check = {} # Store last OCR result for each rectangle
234
237
  last_oneocr_times = {} # Store last OCR time for each rectangle
235
238
  text_stable_start_times = {} # Store the start time when text becomes stable for each rectangle
239
+ previous_imgs = {}
236
240
  orig_text_results = {} # Store original text results for each rectangle
237
241
  TEXT_APPEARENCE_DELAY = get_ocr_scan_rate() * 1000 + 500 # Adjust as needed
238
242
 
239
243
  def text_callback(text, orig_text, rectangle_index, time, img=None):
240
244
  global twopassocr, ocr2, last_oneocr_results_to_check, last_oneocr_times, text_stable_start_times, orig_text_results
241
245
  orig_text_string = ''.join([item for item in orig_text if item is not None]) if orig_text else ""
246
+ # logger.debug(orig_text_string)
242
247
 
243
248
  current_time = time if time else datetime.now()
244
249
 
245
- previous_text = last_oneocr_results_to_check.get(rectangle_index, "").strip()
250
+ previous_text = last_oneocr_results_to_check.pop(rectangle_index, "").strip()
246
251
  previous_orig_text = orig_text_results.get(rectangle_index, "").strip()
247
252
 
248
253
  # print(previous_orig_text)
249
254
  # if orig_text:
250
255
  # print(orig_text_string)
251
-
256
+ if not twopassocr:
257
+ img.save(os.path.join(get_temporary_directory(), "last_successful_ocr.png"))
258
+ send_result(text, time)
252
259
  if not text:
253
260
  if previous_text:
254
261
  if rectangle_index in text_stable_start_times:
255
- stable_time = text_stable_start_times[rectangle_index]
262
+ stable_time = text_stable_start_times.pop(rectangle_index)
263
+ previous_img = previous_imgs.pop(rectangle_index)
256
264
  previous_result = last_ocr1_results[rectangle_index]
257
265
  if previous_result and fuzz.ratio(previous_result, previous_text) >= 80:
258
266
  logger.info("Seems like the same text, not " + "doing second OCR" if twopassocr else "sending")
259
- del last_oneocr_results_to_check[rectangle_index]
260
267
  return
261
268
  if previous_orig_text and fuzz.ratio(orig_text_string, previous_orig_text) >= 80:
262
269
  logger.info("Seems like Text we already sent, not doing anything.")
263
- del last_oneocr_results_to_check[rectangle_index]
264
270
  return
265
271
  orig_text_results[rectangle_index] = orig_text_string
266
- if twopassocr:
267
- do_second_ocr(previous_text, rectangle_index, time, img)
268
- else:
269
- if get_config().advanced.ocr_sends_to_clipboard:
270
- import pyperclip
271
- pyperclip.copy(text)
272
- websocket_server_thread.send_text(previous_text, stable_time)
273
- img.save(os.path.join(get_app_directory(), "temp", "last_successful_ocr.png"))
272
+ do_second_ocr(previous_text, rectangle_index, stable_time, previous_img)
274
273
  last_ocr1_results[rectangle_index] = previous_text
275
- del text_stable_start_times[rectangle_index]
276
- del last_oneocr_results_to_check[rectangle_index]
277
274
  return
278
275
  return
279
276
 
@@ -281,6 +278,7 @@ def text_callback(text, orig_text, rectangle_index, time, img=None):
281
278
  last_oneocr_results_to_check[rectangle_index] = text
282
279
  last_oneocr_times[rectangle_index] = current_time
283
280
  text_stable_start_times[rectangle_index] = current_time
281
+ previous_imgs[rectangle_index] = img
284
282
  return
285
283
 
286
284
  stable = text_stable_start_times.get(rectangle_index)
@@ -294,6 +292,7 @@ def text_callback(text, orig_text, rectangle_index, time, img=None):
294
292
  else:
295
293
  last_oneocr_results_to_check[rectangle_index] = text
296
294
  last_oneocr_times[rectangle_index] = current_time
295
+ previous_imgs[rectangle_index] = img
297
296
 
298
297
  done = False
299
298
 
@@ -0,0 +1,186 @@
1
+ import uuid
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from difflib import SequenceMatcher
5
+ from typing import Optional
6
+
7
+ from GameSentenceMiner.configuration import logger, get_config
8
+ from GameSentenceMiner.model import AnkiCard
9
+ from GameSentenceMiner.util import remove_html_and_cloze_tags
10
+
11
+ initial_time = datetime.now()
12
+
13
+
14
+ @dataclass
15
+ class GameLine:
16
+ id: str
17
+ text: str
18
+ time: datetime
19
+ prev: 'GameLine | None'
20
+ next: 'GameLine | None'
21
+ index: int = 0
22
+
23
+ def get_previous_time(self):
24
+ if self.prev:
25
+ return self.prev.time
26
+ return initial_time
27
+
28
+ def get_next_time(self):
29
+ if self.next:
30
+ return self.next.time
31
+ return 0
32
+
33
+ def __str__(self):
34
+ return str({"text": self.text, "time": self.time})
35
+
36
+
37
+ @dataclass
38
+ class GameText:
39
+ values: list[GameLine]
40
+ values_dict: dict[str, GameLine]
41
+ game_line_index = 0
42
+
43
+ def __init__(self):
44
+ self.values = []
45
+ self.values_dict = {}
46
+
47
+ def __getitem__(self, key):
48
+ return self.values[key]
49
+
50
+ def get_by_id(self, line_id: str) -> Optional[GameLine]:
51
+ if not self.values_dict:
52
+ return None
53
+ return self.values_dict.get(line_id)
54
+
55
+ def get_time(self, line_text: str, occurrence: int = -1) -> datetime:
56
+ matches = [line for line in self.values if line.text == line_text]
57
+ if matches:
58
+ return matches[occurrence].time # Default to latest
59
+ return initial_time
60
+
61
+ def get_event(self, line_text: str, occurrence: int = -1) -> GameLine | None:
62
+ matches = [line for line in self.values if line.text == line_text]
63
+ if matches:
64
+ return matches[occurrence]
65
+ return None
66
+
67
+ def add_line(self, line_text, line_time=None):
68
+ if not line_text:
69
+ return
70
+ line_id = str(uuid.uuid1())
71
+ new_line = GameLine(
72
+ id=line_id, # Time-based UUID as an integer
73
+ text=line_text,
74
+ time=line_time if line_time else datetime.now(),
75
+ prev=self.values[-1] if self.values else None,
76
+ next=None,
77
+ index=self.game_line_index
78
+ )
79
+ self.values_dict[line_id] = new_line
80
+ logger.debug(f"Adding line: {new_line}")
81
+ self.game_line_index += 1
82
+ if self.values:
83
+ self.values[-1].next = new_line
84
+ self.values.append(new_line)
85
+ # self.remove_old_events(datetime.now() - timedelta(minutes=10))
86
+
87
+ def has_line(self, line_text) -> bool:
88
+ for game_line in self.values:
89
+ if game_line.text == line_text:
90
+ return True
91
+ return False
92
+
93
+
94
+ text_log = GameText()
95
+
96
+
97
+ def similar(a, b):
98
+ return SequenceMatcher(None, a, b).ratio()
99
+
100
+
101
+ def one_contains_the_other(a, b):
102
+ return a in b or b in a
103
+
104
+
105
+ def lines_match(a, b):
106
+ similarity = similar(a, b)
107
+ logger.debug(f"Comparing: {a} with {b} - Similarity: {similarity}, Or One contains the other: {one_contains_the_other(a, b)}")
108
+ return similar(a, b) >= 0.60 or one_contains_the_other(a, b)
109
+
110
+
111
+ def get_text_event(last_note) -> GameLine:
112
+ lines = text_log.values
113
+
114
+ if not lines:
115
+ raise Exception("No lines in history. Text is required from either clipboard or websocket for GSM to work. Please check your setup/config.")
116
+
117
+ if not last_note:
118
+ return lines[-1]
119
+
120
+ sentence = last_note.get_field(get_config().anki.sentence_field)
121
+ if not sentence:
122
+ return lines[-1]
123
+
124
+ for line in reversed(lines):
125
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)):
126
+ return line
127
+
128
+ logger.debug("Couldn't find a match in history, using last event")
129
+ return lines[-1]
130
+
131
+
132
+ def get_line_and_future_lines(last_note):
133
+ if not last_note:
134
+ return []
135
+
136
+ sentence = last_note.get_field(get_config().anki.sentence_field)
137
+ found_lines = []
138
+ if sentence:
139
+ found = False
140
+ for line in text_log.values:
141
+ if found:
142
+ found_lines.append(line.text)
143
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)): # 80% similarity threshold
144
+ found = True
145
+ found_lines.append(line.text)
146
+ return found_lines
147
+
148
+
149
+ def get_mined_line(last_note: AnkiCard, lines):
150
+ if not last_note:
151
+ return lines[-1]
152
+ if not lines:
153
+ lines = get_all_lines()
154
+
155
+ sentence = last_note.get_field(get_config().anki.sentence_field)
156
+ for line in lines:
157
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)):
158
+ return line
159
+ return lines[-1]
160
+
161
+
162
+ def get_time_of_line(line):
163
+ return text_log.get_time(line)
164
+
165
+
166
+ def get_all_lines():
167
+ return text_log.values
168
+
169
+
170
+ def get_text_log() -> GameText:
171
+ return text_log
172
+
173
+ def add_line(current_line_after_regex, line_time):
174
+ text_log.add_line(current_line_after_regex, line_time)
175
+
176
+ def get_line_by_id(line_id: str) -> Optional[GameLine]:
177
+ """
178
+ Retrieve a GameLine by its unique ID.
179
+
180
+ Args:
181
+ line_id (str): The unique identifier of the GameLine.
182
+
183
+ Returns:
184
+ Optional[GameLine]: The GameLine object if found, otherwise None.
185
+ """
186
+ return text_log.get_by_id(line_id)
GameSentenceMiner/util.py CHANGED
@@ -1,10 +1,9 @@
1
- import importlib
1
+ import json
2
2
  import os
3
3
  import random
4
4
  import re
5
5
  import string
6
6
  import subprocess
7
- import sys
8
7
  import threading
9
8
  import time
10
9
  from datetime import datetime
@@ -206,4 +205,62 @@ def import_vad_models():
206
205
  from GameSentenceMiner.vad import whisper_helper
207
206
  if get_config().vad.is_vosk():
208
207
  from GameSentenceMiner.vad import vosk_helper
209
- return silero_trim, whisper_helper, vosk_helper
208
+ return silero_trim, whisper_helper, vosk_helper
209
+
210
+
211
+ def isascii(s: str):
212
+ try:
213
+ return s.isascii()
214
+ except:
215
+ try:
216
+ s.encode("ascii")
217
+ return True
218
+ except:
219
+ return False
220
+
221
+ def do_text_replacements(text, replacements_json):
222
+ if not text:
223
+ return text
224
+
225
+ replacements = {}
226
+ if os.path.exists(replacements_json):
227
+ with open(replacements_json, 'r', encoding='utf-8') as f:
228
+ replacements.update(json.load(f))
229
+
230
+ if replacements.get("enabled", False):
231
+ orig_text = text
232
+ filters = replacements.get("args", {}).get("replacements", {})
233
+ for fil, replacement in filters.items():
234
+ if not fil:
235
+ continue
236
+ if fil.startswith("re:"):
237
+ pattern = fil[3:]
238
+ try:
239
+ text = re.sub(pattern, replacement, text)
240
+ except Exception:
241
+ logger.error(f"Invalid regex pattern: {pattern}")
242
+ continue
243
+ if isascii(fil):
244
+ text = re.sub(r"\b{}\b".format(re.escape(fil)), replacement, text)
245
+ else:
246
+ text = text.replace(fil, replacement)
247
+ if text != orig_text:
248
+ logger.info(f"Text replaced: '{orig_text}' -> '{text}' using replacements.")
249
+ return text
250
+
251
+
252
+ TEXT_REPLACEMENTS_FILE = os.path.join(os.getenv('APPDATA'), 'GameSentenceMiner', 'config', 'text_replacements.json')
253
+ OCR_REPLACEMENTS_FILE = os.path.join(os.getenv('APPDATA'), 'GameSentenceMiner', 'config', 'ocr_replacements.json')
254
+ os.makedirs(os.path.dirname(TEXT_REPLACEMENTS_FILE), exist_ok=True)
255
+
256
+ import urllib.request
257
+
258
+ if not os.path.exists(TEXT_REPLACEMENTS_FILE):
259
+ url = "https://raw.githubusercontent.com/bpwhelan/GameSentenceMiner/refs/heads/main/electron-src/assets/ocr_replacements.json"
260
+ try:
261
+ with urllib.request.urlopen(url) as response:
262
+ data = response.read().decode('utf-8')
263
+ with open(TEXT_REPLACEMENTS_FILE, 'w', encoding='utf-8') as f:
264
+ f.write(data)
265
+ except Exception as e:
266
+ logger.error(f"Failed to fetch JSON from {url}: {e}")
File without changes
File without changes
Binary file
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="67" height="64" viewBox="0 0 67 64"><image width="67" height="64" xlink:href="data:image/png;base64,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"></image><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
2
+ @media (prefers-color-scheme: dark) { :root { filter: none; } }
3
+ </style></svg>
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "MyWebSite",
3
+ "short_name": "MySite",
4
+ "icons": [
5
+ {
6
+ "src": "/web-app-manifest-192x192.png",
7
+ "sizes": "192x192",
8
+ "type": "image/png",
9
+ "purpose": "maskable"
10
+ },
11
+ {
12
+ "src": "/web-app-manifest-512x512.png",
13
+ "sizes": "512x512",
14
+ "type": "image/png",
15
+ "purpose": "maskable"
16
+ }
17
+ ],
18
+ "theme_color": "#ffffff",
19
+ "background_color": "#ffffff",
20
+ "display": "standalone"
21
+ }