GameSentenceMiner 2.14.9__py3-none-any.whl → 2.14.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. GameSentenceMiner/ai/__init__.py +0 -0
  2. GameSentenceMiner/ai/ai_prompting.py +473 -0
  3. GameSentenceMiner/ocr/__init__.py +0 -0
  4. GameSentenceMiner/ocr/gsm_ocr_config.py +174 -0
  5. GameSentenceMiner/ocr/ocrconfig.py +129 -0
  6. GameSentenceMiner/ocr/owocr_area_selector.py +629 -0
  7. GameSentenceMiner/ocr/owocr_helper.py +638 -0
  8. GameSentenceMiner/ocr/ss_picker.py +140 -0
  9. GameSentenceMiner/owocr/owocr/__init__.py +1 -0
  10. GameSentenceMiner/owocr/owocr/__main__.py +9 -0
  11. GameSentenceMiner/owocr/owocr/config.py +148 -0
  12. GameSentenceMiner/owocr/owocr/lens_betterproto.py +1238 -0
  13. GameSentenceMiner/owocr/owocr/ocr.py +1690 -0
  14. GameSentenceMiner/owocr/owocr/run.py +1818 -0
  15. GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py +109 -0
  16. GameSentenceMiner/tools/__init__.py +0 -0
  17. GameSentenceMiner/tools/audio_offset_selector.py +215 -0
  18. GameSentenceMiner/tools/ss_selector.py +135 -0
  19. GameSentenceMiner/tools/window_transparency.py +214 -0
  20. GameSentenceMiner/util/__init__.py +0 -0
  21. GameSentenceMiner/util/communication/__init__.py +22 -0
  22. GameSentenceMiner/util/communication/send.py +7 -0
  23. GameSentenceMiner/util/communication/websocket.py +94 -0
  24. GameSentenceMiner/util/configuration.py +1199 -0
  25. GameSentenceMiner/util/db.py +408 -0
  26. GameSentenceMiner/util/downloader/Untitled_json.py +472 -0
  27. GameSentenceMiner/util/downloader/__init__.py +0 -0
  28. GameSentenceMiner/util/downloader/download_tools.py +194 -0
  29. GameSentenceMiner/util/downloader/oneocr_dl.py +250 -0
  30. GameSentenceMiner/util/electron_config.py +259 -0
  31. GameSentenceMiner/util/ffmpeg.py +571 -0
  32. GameSentenceMiner/util/get_overlay_coords.py +366 -0
  33. GameSentenceMiner/util/gsm_utils.py +323 -0
  34. GameSentenceMiner/util/model.py +206 -0
  35. GameSentenceMiner/util/notification.py +157 -0
  36. GameSentenceMiner/util/text_log.py +214 -0
  37. GameSentenceMiner/util/win10toast/__init__.py +154 -0
  38. GameSentenceMiner/util/win10toast/__main__.py +22 -0
  39. GameSentenceMiner/web/__init__.py +0 -0
  40. GameSentenceMiner/web/service.py +132 -0
  41. GameSentenceMiner/web/static/__init__.py +0 -0
  42. GameSentenceMiner/web/static/apple-touch-icon.png +0 -0
  43. GameSentenceMiner/web/static/favicon-96x96.png +0 -0
  44. GameSentenceMiner/web/static/favicon.ico +0 -0
  45. GameSentenceMiner/web/static/favicon.svg +3 -0
  46. GameSentenceMiner/web/static/site.webmanifest +21 -0
  47. GameSentenceMiner/web/static/style.css +292 -0
  48. GameSentenceMiner/web/static/web-app-manifest-192x192.png +0 -0
  49. GameSentenceMiner/web/static/web-app-manifest-512x512.png +0 -0
  50. GameSentenceMiner/web/templates/__init__.py +0 -0
  51. GameSentenceMiner/web/templates/index.html +50 -0
  52. GameSentenceMiner/web/templates/text_replacements.html +238 -0
  53. GameSentenceMiner/web/templates/utility.html +483 -0
  54. GameSentenceMiner/web/texthooking_page.py +584 -0
  55. GameSentenceMiner/wip/__init___.py +0 -0
  56. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/METADATA +1 -1
  57. gamesentenceminer-2.14.11.dist-info/RECORD +79 -0
  58. gamesentenceminer-2.14.9.dist-info/RECORD +0 -24
  59. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/WHEEL +0 -0
  60. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/entry_points.txt +0 -0
  61. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/licenses/LICENSE +0 -0
  62. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,206 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, List
3
+ from enum import Enum
4
+
5
+ from dataclasses_json import dataclass_json
6
+
7
+ from GameSentenceMiner.util.configuration import get_config, logger, save_current_config
8
+
9
+
10
+ # OBS
11
+ @dataclass_json
12
+ @dataclass
13
+ class SceneInfo:
14
+ currentProgramSceneName: str
15
+ currentProgramSceneUuid: str
16
+ sceneName: str
17
+ sceneUuid: str
18
+
19
+
20
+ @dataclass_json
21
+ @dataclass
22
+ class SceneItemTransform:
23
+ alignment: int
24
+ boundsAlignment: int
25
+ boundsHeight: float
26
+ boundsType: str
27
+ boundsWidth: float
28
+ cropBottom: int
29
+ cropLeft: int
30
+ cropRight: int
31
+ cropToBounds: bool
32
+ cropTop: int
33
+ height: float
34
+ positionX: float
35
+ positionY: float
36
+ rotation: float
37
+ scaleX: float
38
+ scaleY: float
39
+ sourceHeight: float
40
+ sourceWidth: float
41
+ width: float
42
+
43
+
44
+ @dataclass_json
45
+ @dataclass
46
+ class SceneItem:
47
+ inputKind: str
48
+ isGroup: Optional[bool]
49
+ sceneItemBlendMode: str
50
+ sceneItemEnabled: bool
51
+ sceneItemId: int
52
+ sceneItemIndex: int
53
+ sceneItemLocked: bool
54
+ sceneItemTransform: SceneItemTransform
55
+ sourceName: str
56
+ sourceType: str
57
+ sourceUuid: str
58
+
59
+ # def __init__(self, **kwargs):
60
+ # self.inputKind = kwargs['inputKind']
61
+ # self.isGroup = kwargs['isGroup']
62
+ # self.sceneItemBlendMode = kwargs['sceneItemBlendMode']
63
+ # self.sceneItemEnabled = kwargs['sceneItemEnabled']
64
+ # self.sceneItemId = kwargs['sceneItemId']
65
+ # self.sceneItemIndex = kwargs['sceneItemIndex']
66
+ # self.sceneItemLocked = kwargs['sceneItemLocked']
67
+ # self.sceneItemTransform = SceneItemTransform(**kwargs['sceneItemTransform'])
68
+ # self.sourceName = kwargs['sourceName']
69
+ # self.sourceType = kwargs['sourceType']
70
+ # self.sourceUuid = kwargs['sourceUuid']
71
+
72
+
73
+ @dataclass_json
74
+ @dataclass
75
+ class SceneItemsResponse:
76
+ sceneItems: List[SceneItem]
77
+
78
+ # def __init__(self, **kwargs):
79
+ # self.sceneItems = [SceneItem(**item) for item in kwargs['sceneItems']]
80
+
81
+
82
+ @dataclass_json
83
+ @dataclass
84
+ class RecordDirectory:
85
+ recordDirectory: str
86
+
87
+
88
+ @dataclass_json
89
+ @dataclass
90
+ class SceneItemInfo:
91
+ sceneIndex: int
92
+ sceneName: str
93
+ sceneUuid: str
94
+
95
+
96
+ @dataclass_json
97
+ @dataclass
98
+ class SceneListResponse:
99
+ scenes: List[SceneItemInfo]
100
+ currentProgramSceneName: Optional[str] = None
101
+ currentProgramSceneUuid: Optional[str] = None
102
+ currentPreviewSceneName: Optional[str] = None
103
+ currentPreviewSceneUuid: Optional[str] = None
104
+
105
+ #
106
+ # @dataclass_json
107
+ # @dataclass
108
+ # class SourceActive:
109
+ # videoActive: bool
110
+ # videoShowing: bool
111
+
112
+ @dataclass_json
113
+ @dataclass
114
+ class AnkiField:
115
+ value: str
116
+ order: int
117
+
118
+ @dataclass_json
119
+ @dataclass
120
+ class AnkiCard:
121
+ noteId: int
122
+ tags: list[str]
123
+ fields: dict[str, AnkiField]
124
+ cards: list[int]
125
+ alternatives = {
126
+ "word_field": ["Front", "Word", "TargetWord", "Expression"],
127
+ "sentence_field": ["Example", "Context", "Back", "Sentence"],
128
+ "picture_field": ["Image", "Visual", "Media", "Picture", "Screenshot", 'AnswerImage'],
129
+ "sentence_audio_field": ["SentenceAudio"]
130
+ }
131
+
132
+ def get_field(self, field_name: str) -> str:
133
+ if self.has_field(field_name):
134
+ return self.fields[field_name].value
135
+ else:
136
+ raise ValueError(f"Field '{field_name}' not found in AnkiCard. Please make sure your Anki Field Settings in GSM Match your fields in your Anki Note!")
137
+
138
+ def has_field (self, field_name: str) -> bool:
139
+ return field_name in self.fields
140
+
141
+ def __post_init__(self):
142
+ config = get_config()
143
+ changes_found = False
144
+ if not self.has_field(config.anki.word_field):
145
+ found_alternative_field, field = self.find_field(config.anki.word_field, "word_field")
146
+ if found_alternative_field:
147
+ logger.warning(f"{config.anki.word_field} Not found in Anki Card! Saving alternative field '{field}' for word_field to settings.")
148
+ config.anki.word_field = field
149
+ changes_found = True
150
+
151
+ if not self.has_field(config.anki.sentence_field):
152
+ found_alternative_field, field = self.find_field(config.anki.sentence_field, "sentence_field")
153
+ if found_alternative_field:
154
+ logger.warning(f"{config.anki.sentence_field} Not found in Anki Card! Saving alternative field '{field}' for sentence_field to settings.")
155
+ config.anki.sentence_field = field
156
+ changes_found = True
157
+
158
+ if not self.has_field(config.anki.picture_field):
159
+ found_alternative_field, field = self.find_field(config.anki.picture_field, "picture_field")
160
+ if found_alternative_field:
161
+ logger.warning(f"{config.anki.picture_field} Not found in Anki Card! Saving alternative field '{field}' for picture_field to settings.")
162
+ config.anki.picture_field = field
163
+ changes_found = True
164
+
165
+ if not self.has_field(config.anki.sentence_audio_field):
166
+ found_alternative_field, field = self.find_field(config.anki.sentence_audio_field, "sentence_audio_field")
167
+ if found_alternative_field:
168
+ logger.warning(f"{config.anki.sentence_audio_field} Not found in Anki Card! Saving alternative field '{field}' for sentence_audio_field to settings.")
169
+ config.anki.sentence_audio_field = field
170
+ changes_found = True
171
+
172
+ if changes_found:
173
+ save_current_config(config)
174
+
175
+ def find_field(self, field, field_type):
176
+ if field in self.fields:
177
+ return False, field
178
+
179
+ for alt_field in self.alternatives[field_type]:
180
+ for key in self.fields:
181
+ if alt_field.lower() == key.lower():
182
+ return True, key
183
+
184
+ return False, None
185
+
186
+
187
+ class VADResult:
188
+ def __init__(self, success: bool, start: float, end: float, model: str, segments: list = None, output_audio: str = None):
189
+ self.success = success
190
+ self.start = start
191
+ self.end = end
192
+ self.model = model
193
+ self.segments = segments if segments is not None else []
194
+ self.output_audio = output_audio
195
+
196
+ def __repr__(self):
197
+ return f"VADResult(success={self.success}, start={self.start}, end={self.end}, model={self.model}, output_audio={self.output_audio})"
198
+
199
+ def trim_successful_string(self):
200
+ if self.success:
201
+ if get_config().vad.trim_beginning:
202
+ return f"Trimmed audio from {self.start:.2f} to {self.end:.2f} seconds using {self.model}."
203
+ else:
204
+ return f"Trimmed end of audio to {self.end:.2f} seconds using {self.model}."
205
+ else:
206
+ return f"Failed to trim audio using {self.model}."
@@ -0,0 +1,157 @@
1
+ import requests
2
+ from plyer import notification
3
+ from GameSentenceMiner.util.configuration import logger, is_windows
4
+
5
+ if is_windows():
6
+ from GameSentenceMiner.util.win10toast import ToastNotifier
7
+
8
+ if is_windows():
9
+ class MyToastNotifier(ToastNotifier):
10
+ def __init__(self):
11
+ super().__init__()
12
+
13
+ def on_destroy(self, hwnd, msg, wparam, lparam):
14
+ super().on_destroy(hwnd, msg, wparam, lparam)
15
+ return 0
16
+
17
+ if is_windows():
18
+ notifier = MyToastNotifier()
19
+ else:
20
+ notifier = notification
21
+
22
+
23
+ def open_browser_window(note_id, query=None):
24
+ url = "http://localhost:8765"
25
+ headers = {'Content-Type': 'application/json'}
26
+
27
+ data = {
28
+ "action": "guiBrowse",
29
+ "version": 6,
30
+ "params": {
31
+ "query": f"nid:{note_id}" if not query else query,
32
+ }
33
+ }
34
+
35
+ try:
36
+ if query:
37
+ blank_req_data = {
38
+ "action": "guiBrowse",
39
+ "version": 6,
40
+ "params": {
41
+ "query": "nid:1",
42
+ }
43
+ }
44
+ requests.post(url, json=blank_req_data, headers=headers)
45
+ response = requests.post(url, json=data, headers=headers)
46
+ if response.status_code == 200:
47
+ if query:
48
+ logger.info(f"Opened Anki browser with query: {query}")
49
+ else:
50
+ logger.info(f"Opened Anki note in browser with ID {note_id}")
51
+ else:
52
+ logger.error(f"Failed to open Anki note with ID {note_id}")
53
+ except Exception as e:
54
+ logger.info(f"Error connecting to AnkiConnect: {e}")
55
+
56
+
57
+ def open_anki_card(note_id):
58
+ url = "http://localhost:8765"
59
+ headers = {'Content-Type': 'application/json'}
60
+
61
+ data = {
62
+ "action": "guiEditNote",
63
+ "version": 6,
64
+ "params": {
65
+ "note": note_id
66
+ }
67
+ }
68
+
69
+ try:
70
+ response = requests.post(url, json=data, headers=headers)
71
+ if response.status_code == 200:
72
+ logger.info(f"Opened Anki note with ID {note_id}")
73
+ else:
74
+ logger.error(f"Failed to open Anki note with ID {note_id}")
75
+ except Exception as e:
76
+ logger.info(f"Error connecting to AnkiConnect: {e}")
77
+
78
+
79
+ def send_notification(title, message, timeout):
80
+ try:
81
+ if is_windows():
82
+ notifier.show_toast(
83
+ title, message, duration=timeout, threaded=True)
84
+ else:
85
+ notification.notify(
86
+ title=title,
87
+ message=message,
88
+ app_name="GameSentenceMiner",
89
+ timeout=timeout # Notification disappears after 5 seconds
90
+ )
91
+ except Exception as e:
92
+ logger.error(f"Failed to send notification: {e}")
93
+
94
+
95
+ def send_note_updated(tango):
96
+ send_notification(
97
+ title="Anki Card Updated",
98
+ message=f"Audio and/or Screenshot added to note: {tango}",
99
+ timeout=5 # Notification disappears after 5 seconds
100
+ )
101
+
102
+
103
+ def send_screenshot_updated(tango):
104
+ send_notification(
105
+ title="Anki Card Updated",
106
+ message=f"Screenshot updated on note: {tango}",
107
+ timeout=5 # Notification disappears after 5 seconds
108
+ )
109
+
110
+
111
+ def send_screenshot_saved(path):
112
+ send_notification(
113
+ title="Screenshot Saved",
114
+ message=f"Screenshot saved to : {path}",
115
+ timeout=5 # Notification disappears after 5 seconds
116
+ )
117
+
118
+
119
+ def send_audio_generated_notification(audio_path):
120
+ send_notification(
121
+ title="Audio Trimmed",
122
+ message=f"Audio Trimmed and placed at {audio_path}",
123
+ timeout=5 # Notification disappears after 5 seconds
124
+ )
125
+
126
+
127
+ def send_check_obs_notification(reason):
128
+ send_notification(
129
+ title="OBS Replay Invalid",
130
+ message=f"Check OBS Settings! Reason: {reason}",
131
+ timeout=5 # Notification disappears after 5 seconds
132
+ )
133
+
134
+
135
+ def send_error_no_anki_update():
136
+ send_notification(
137
+ title="Error",
138
+ message=f"Anki Card not updated, Check Console for Reason!",
139
+ timeout=5 # Notification disappears after 5 seconds
140
+ )
141
+
142
+ def send_error_notification(message):
143
+ send_notification(
144
+ title="Error",
145
+ message=message,
146
+ timeout=5 # Notification disappears after 5 seconds
147
+ )
148
+
149
+
150
+ if __name__ == "__main__":
151
+ send_note_updated("TestTango")
152
+ send_screenshot_updated("TestTango")
153
+ send_screenshot_saved("C:/Screenshots/test.png")
154
+ send_audio_generated_notification("C:/Audio/test.mp3")
155
+ send_check_obs_notification("Replay buffer not active")
156
+ send_error_no_anki_update()
157
+ send_error_notification("Custom error message for testing")
@@ -0,0 +1,214 @@
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
+ import rapidfuzz
8
+
9
+ from GameSentenceMiner.util.gsm_utils import remove_html_and_cloze_tags
10
+ from GameSentenceMiner.util.configuration import logger, get_config, gsm_state
11
+ from GameSentenceMiner.util.model import AnkiCard
12
+ import re
13
+
14
+ initial_time = datetime.now()
15
+
16
+
17
+ @dataclass
18
+ class GameLine:
19
+ id: str
20
+ text: str
21
+ time: datetime
22
+ prev: 'GameLine | None'
23
+ next: 'GameLine | None'
24
+ index: int = 0
25
+ scene: str = ""
26
+ TL: str = ""
27
+
28
+ def get_previous_time(self):
29
+ if self.prev:
30
+ return self.prev.time
31
+ return initial_time
32
+
33
+ def get_next_time(self):
34
+ if self.next:
35
+ return self.next.time
36
+ return 0
37
+
38
+ def set_TL(self, tl: str):
39
+ self.TL = tl
40
+
41
+ def get_stripped_text(self):
42
+ return self.text.replace('\n', '').strip()
43
+
44
+ def __str__(self):
45
+ return str({"text": self.text, "time": self.time})
46
+
47
+
48
+ @dataclass
49
+ class GameText:
50
+ values: list[GameLine]
51
+ values_dict: dict[str, GameLine]
52
+ game_line_index = 0
53
+
54
+ def __init__(self):
55
+ self.values = []
56
+ self.values_dict = {}
57
+
58
+ def __getitem__(self, index):
59
+ return self.values[index]
60
+
61
+ def get_by_id(self, line_id: str) -> Optional[GameLine]:
62
+ if not self.values_dict:
63
+ return None
64
+ return self.values_dict.get(line_id)
65
+
66
+ def get_time(self, line_text: str, occurrence: int = -1) -> datetime:
67
+ matches = [line for line in self.values if line.text == line_text]
68
+ if matches:
69
+ return matches[occurrence].time # Default to latest
70
+ return initial_time
71
+
72
+ def get_event(self, line_text: str, occurrence: int = -1) -> GameLine | None:
73
+ matches = [line for line in self.values if line.text == line_text]
74
+ if matches:
75
+ return matches[occurrence]
76
+ return None
77
+
78
+ def add_line(self, line_text, line_time=None):
79
+ if not line_text:
80
+ return
81
+ line_id = str(uuid.uuid1())
82
+ new_line = GameLine(
83
+ id=line_id, # Time-based UUID as an integer
84
+ text=line_text,
85
+ time=line_time or datetime.now(),
86
+ prev=self.values[-1] if self.values else None,
87
+ next=None,
88
+ index=self.game_line_index,
89
+ scene=gsm_state.current_game or ""
90
+ )
91
+ self.values_dict[line_id] = new_line
92
+ logger.debug(f"Adding line: {new_line}")
93
+ self.game_line_index += 1
94
+ if self.values:
95
+ self.values[-1].next = new_line
96
+ self.values.append(new_line)
97
+ # self.remove_old_events(datetime.now() - timedelta(minutes=10))
98
+
99
+ def has_line(self, line_text) -> bool:
100
+ for game_line in self.values:
101
+ if game_line.text == line_text:
102
+ return True
103
+ return False
104
+
105
+ def get_last_line(self):
106
+ if self.values:
107
+ return self.values[-1]
108
+ return None
109
+
110
+
111
+ game_log = GameText()
112
+
113
+ def strip_whitespace_and_punctuation(text: str) -> str:
114
+ """
115
+ Strips whitespace and punctuation from the given text.
116
+ """
117
+ # Remove all whitespace and specified punctuation using regex
118
+ # Includes Japanese and common punctuation
119
+ return re.sub(r'[\s 、。「」【】《》., ]', '', text).strip()
120
+
121
+
122
+ def lines_match(texthooker_sentence, anki_sentence):
123
+ # Replace newlines, spaces, other whitespace characters, AND japanese punctuation
124
+ texthooker_sentence = strip_whitespace_and_punctuation(texthooker_sentence)
125
+ anki_sentence = strip_whitespace_and_punctuation(anki_sentence)
126
+ similarity = rapidfuzz.fuzz.ratio(texthooker_sentence, anki_sentence)
127
+ logger.debug(f"Comparing sentences: '{texthooker_sentence}' and '{anki_sentence}' - Similarity: {similarity}")
128
+ if texthooker_sentence in anki_sentence:
129
+ logger.debug(f"One contains the other: {texthooker_sentence} in {anki_sentence} - Similarity: {similarity}")
130
+ elif anki_sentence in texthooker_sentence:
131
+ logger.debug(f"One contains the other: {anki_sentence} in {texthooker_sentence} - Similarity: {similarity}")
132
+ return (anki_sentence in texthooker_sentence) or (texthooker_sentence in anki_sentence) or (similarity >= 80)
133
+
134
+
135
+ def get_text_event(last_note) -> GameLine:
136
+ lines = game_log.values
137
+
138
+ if not lines:
139
+ raise Exception("No voicelines in GSM. GSM can only do work on text that has been sent to it since it started. If you are not getting any text into GSM, please check your setup/config.")
140
+
141
+ if not last_note:
142
+ return lines[-1]
143
+
144
+ sentence = last_note.get_field(get_config().anki.sentence_field)
145
+ if not sentence:
146
+ return lines[-1]
147
+
148
+ for line in reversed(lines):
149
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)):
150
+ return line
151
+
152
+ logger.info("Could not find matching sentence from GSM's history. Using the latest line.")
153
+ return lines[-1]
154
+
155
+
156
+ def get_line_and_future_lines(last_note):
157
+ if not last_note:
158
+ return []
159
+
160
+ sentence = last_note.get_field(get_config().anki.sentence_field)
161
+ found_lines = []
162
+ if sentence:
163
+ found = False
164
+ for line in game_log.values:
165
+ if found:
166
+ found_lines.append(line)
167
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)): # 80% similarity threshold
168
+ found = True
169
+ found_lines.append(line)
170
+ return found_lines
171
+
172
+
173
+ def get_mined_line(last_note: AnkiCard, lines=None):
174
+ if lines is None:
175
+ lines = []
176
+ if not last_note:
177
+ return lines[-1]
178
+ if not lines:
179
+ lines = get_all_lines()
180
+ if not lines:
181
+ raise Exception("No voicelines in GSM. GSM can only do work on text that has been sent to it since it started. If you are not getting any text into GSM, please check your setup/config.")
182
+
183
+ sentence = last_note.get_field(get_config().anki.sentence_field)
184
+ for line in reversed(lines):
185
+ if lines_match(line.get_stripped_text(), remove_html_and_cloze_tags(sentence)):
186
+ return line
187
+ return lines[-1]
188
+
189
+
190
+ def get_time_of_line(line):
191
+ return game_log.get_time(line)
192
+
193
+
194
+ def get_all_lines():
195
+ return game_log.values
196
+
197
+
198
+ def get_text_log() -> GameText:
199
+ return game_log
200
+
201
+ def add_line(current_line_after_regex, line_time):
202
+ game_log.add_line(current_line_after_regex, line_time)
203
+
204
+ def get_line_by_id(line_id: str) -> Optional[GameLine]:
205
+ """
206
+ Retrieve a GameLine by its unique ID.
207
+
208
+ Args:
209
+ line_id (str): The unique identifier of the GameLine.
210
+
211
+ Returns:
212
+ Optional[GameLine]: The GameLine object if found, otherwise None.
213
+ """
214
+ return game_log.get_by_id(line_id)