GameSentenceMiner 2.18.15__py3-none-any.whl → 2.18.17__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 (37) hide show
  1. GameSentenceMiner/anki.py +8 -53
  2. GameSentenceMiner/owocr/owocr/ocr.py +3 -2
  3. GameSentenceMiner/owocr/owocr/run.py +5 -1
  4. GameSentenceMiner/ui/anki_confirmation.py +16 -2
  5. GameSentenceMiner/util/configuration.py +6 -9
  6. GameSentenceMiner/util/db.py +11 -7
  7. GameSentenceMiner/util/games_table.py +320 -0
  8. GameSentenceMiner/web/anki_api_endpoints.py +506 -0
  9. GameSentenceMiner/web/database_api.py +239 -117
  10. GameSentenceMiner/web/static/css/loading-skeleton.css +41 -0
  11. GameSentenceMiner/web/static/css/search.css +54 -0
  12. GameSentenceMiner/web/static/css/stats.css +76 -0
  13. GameSentenceMiner/web/static/js/anki_stats.js +304 -50
  14. GameSentenceMiner/web/static/js/database.js +44 -7
  15. GameSentenceMiner/web/static/js/heatmap.js +326 -0
  16. GameSentenceMiner/web/static/js/overview.js +20 -224
  17. GameSentenceMiner/web/static/js/search.js +190 -23
  18. GameSentenceMiner/web/static/js/stats.js +371 -1
  19. GameSentenceMiner/web/stats.py +188 -0
  20. GameSentenceMiner/web/templates/anki_stats.html +145 -58
  21. GameSentenceMiner/web/templates/components/date-range.html +19 -0
  22. GameSentenceMiner/web/templates/components/html-head.html +45 -0
  23. GameSentenceMiner/web/templates/components/js-config.html +37 -0
  24. GameSentenceMiner/web/templates/components/popups.html +15 -0
  25. GameSentenceMiner/web/templates/components/settings-modal.html +233 -0
  26. GameSentenceMiner/web/templates/database.html +13 -3
  27. GameSentenceMiner/web/templates/goals.html +9 -31
  28. GameSentenceMiner/web/templates/overview.html +16 -223
  29. GameSentenceMiner/web/templates/search.html +46 -0
  30. GameSentenceMiner/web/templates/stats.html +49 -311
  31. GameSentenceMiner/web/texthooking_page.py +4 -66
  32. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/METADATA +1 -1
  33. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/RECORD +37 -28
  34. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/WHEEL +0 -0
  35. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/entry_points.txt +0 -0
  36. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/licenses/LICENSE +0 -0
  37. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/top_level.txt +0 -0
GameSentenceMiner/anki.py CHANGED
@@ -233,12 +233,16 @@ def update_anki_card(last_note: 'AnkiCard', note=None, audio_path='', video_path
233
233
  config_app: 'ConfigApp' = gsm_state.config_app
234
234
  sentence = note['fields'].get(config.anki.sentence_field, last_note.get_field(config.anki.sentence_field))
235
235
 
236
- use_voice, sentence, translation, new_ss_path = config_app.show_anki_confirmation_dialog(
236
+ use_voice, sentence, translation, new_ss_path, add_nsfw_tag = config_app.show_anki_confirmation_dialog(
237
237
  tango, sentence, assets.screenshot_path, assets.audio_path if update_audio_flag else None, translation, ss_time
238
238
  )
239
239
  note['fields'][config.anki.sentence_field] = sentence
240
240
  note['fields'][config.ai.anki_field] = translation
241
241
  assets.screenshot_path = new_ss_path or assets.screenshot_path
242
+
243
+ # Add NSFW tag if checkbox was selected
244
+ if add_nsfw_tag:
245
+ tags.append("NSFW")
242
246
 
243
247
  # 5. If creating new media, store files in Anki's collection. Then update note fields.
244
248
  if not use_existing_files:
@@ -643,59 +647,10 @@ def start_monitoring_anki():
643
647
  obs_thread.start()
644
648
 
645
649
 
646
- # --- Anki Stats Kanji Extraction Utilities ---
650
+ # --- Anki Stats Utilities ---
651
+ # Note: Individual query functions have been removed in favor of the combined endpoint
652
+ # All Anki statistics are now fetched through /api/anki_stats_combined
647
653
 
648
- def get_anki_earliest_date():
649
- """
650
- Fetches the earliest Anki card ID.
651
- """
652
- try:
653
- note_ids = invoke("findCards", query="")
654
- if not note_ids:
655
- return 0
656
-
657
- # Return the first card ID as the "earliest"
658
- # return note_ids[0]
659
- return min(note_ids)
660
-
661
- except Exception as e:
662
- logger.error(f"Failed to fetch kanji from Anki: {e}")
663
- return 0
664
-
665
- def get_all_anki_first_field_kanji(start_timestamp = None, end_timestamp = None):
666
- """
667
- Fetch all notes from Anki and extract unique kanji from the first field of each note.
668
- Returns a set of kanji characters.
669
- Optional filtering by start_timestamp and end_timestamp on note IDs.
670
- """
671
- from GameSentenceMiner.web.stats import is_kanji
672
- try:
673
- note_ids = invoke("findNotes", query="")
674
- if not note_ids:
675
- return set()
676
-
677
- # Filter note IDs by start and end timestamps if provided
678
- if (start_timestamp and end_timestamp):
679
- note_ids = [nid for nid in note_ids if int(start_timestamp) <= nid <= int(end_timestamp)]
680
- if not note_ids:
681
- return set()
682
- kanji_set = set()
683
- batch_size = 1000
684
- for i in range(0, len(note_ids), batch_size):
685
- batch_ids = note_ids[i:i+batch_size]
686
- notes_info = invoke("notesInfo", notes=batch_ids)
687
- for note in notes_info:
688
- fields = note.get("fields", {})
689
- first_field = next(iter(fields.values()), None)
690
- if first_field and "value" in first_field:
691
- first_field_value = first_field["value"]
692
- for char in first_field_value:
693
- if is_kanji(char):
694
- kanji_set.add(char)
695
- return kanji_set
696
- except Exception as e:
697
- logger.error(f"Failed to fetch kanji from Anki: {e}")
698
- return set()
699
654
 
700
655
 
701
656
  if __name__ == "__main__":
@@ -111,8 +111,9 @@ def post_process(text, keep_blank_lines=False):
111
111
  text = '\n'.join([''.join(i.split()) for i in text.splitlines()])
112
112
  else:
113
113
  text = ''.join([''.join(i.split()) for i in text.splitlines()])
114
- text = text.replace('…', '...')
115
- text = re.sub('[・.]{2,}', lambda x: (x.end() - x.start()) * '.', text)
114
+ text = text.replace('…', '・・・')
115
+ text = re.sub('[・.]{2,}', lambda x: (x.end() - x.start()) * '', text)
116
+ text = re.sub(r'・{3,}', '・・・', text)
116
117
  text = jaconv.h2z(text, ascii=True, digit=True)
117
118
  return text
118
119
 
@@ -1392,7 +1392,11 @@ def process_and_write_results(img_or_path, write_to=None, last_result=None, filt
1392
1392
  # print(engine_index)
1393
1393
 
1394
1394
  if res:
1395
- text = do_configured_ocr_replacements(text)
1395
+ if isinstance(text, list):
1396
+ for i, line in enumerate(text):
1397
+ text[i] = do_configured_ocr_replacements(line)
1398
+ else:
1399
+ text = do_configured_ocr_replacements(text)
1396
1400
  if filtering:
1397
1401
  text, orig_text = filtering(text, last_result, engine=engine, is_second_ocr=is_second_ocr)
1398
1402
  if get_ocr_language() == "ja" or get_ocr_language() == "zh":
@@ -36,6 +36,9 @@ class AnkiConfirmationDialog(tk.Toplevel):
36
36
  self.audio_path_label = None # Store reference to audio path label
37
37
  self.tts_button = None # Store reference to TTS button
38
38
  self.tts_status_label = None # Store reference to TTS status label
39
+
40
+ # NSFW tag option
41
+ self.nsfw_tag_var = tk.BooleanVar(value=False)
39
42
 
40
43
  self.title("Confirm Anki Card Details")
41
44
  self.result = None # This will store the user's choice
@@ -152,6 +155,17 @@ class AnkiConfirmationDialog(tk.Toplevel):
152
155
 
153
156
  row += 1
154
157
 
158
+ # NSFW Tag Option
159
+ nsfw_frame = ttk.Frame(main_frame)
160
+ nsfw_frame.grid(row=row, column=0, columnspan=2, pady=10)
161
+ ttk.Checkbutton(
162
+ nsfw_frame,
163
+ text="Add NSFW tag?",
164
+ variable=self.nsfw_tag_var,
165
+ bootstyle="round-toggle"
166
+ ).pack(side="left", padx=5)
167
+ row += 1
168
+
155
169
  # Action Buttons
156
170
  button_frame = ttk.Frame(main_frame)
157
171
  button_frame.grid(row=row, column=0, columnspan=2, pady=15)
@@ -310,13 +324,13 @@ class AnkiConfirmationDialog(tk.Toplevel):
310
324
  # Clean up audio before closing
311
325
  self._cleanup_audio()
312
326
  # The screenshot_path is now correctly updated if the user chose a new one
313
- self.result = (True, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path)
327
+ self.result = (True, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path, self.nsfw_tag_var.get())
314
328
  self.destroy()
315
329
 
316
330
  def _on_no_voice(self):
317
331
  # Clean up audio before closing
318
332
  self._cleanup_audio()
319
- self.result = (False, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path)
333
+ self.result = (False, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path, self.nsfw_tag_var.get())
320
334
  self.destroy()
321
335
 
322
336
  def _on_cancel(self):
@@ -888,15 +888,16 @@ class Config:
888
888
  if profile.advanced.streak_requirement_hours != default_stats.streak_requirement_hours:
889
889
  self.stats.streak_requirement_hours = profile.advanced.streak_requirement_hours
890
890
 
891
+ self.overlay = self.get_config().overlay
892
+
891
893
  # Add a way to migrate certain things based on version if needed, also help with better defaults
892
894
  if self.version:
893
- if self.version != get_current_version():
895
+ current_version = get_current_version()
896
+ if self.version != current_version:
894
897
  from packaging import version
895
- logger.info(f"New Config Found: {self.version} != {get_current_version()}")
898
+ logger.info(f"New Config Found: {self.version} != {current_version}")
896
899
  # Handle version mismatch
897
- changed = False
898
900
  if version.parse(self.version) < version.parse("2.18.0"):
899
- changed = True
900
901
  # Example, doesn't need to be done
901
902
  for profile in self.configs.values():
902
903
  profile.obs.get_game_from_scene = True
@@ -904,11 +905,7 @@ class Config:
904
905
  if profile.vad.selected_vad_model == WHISPER and profile.vad.backup_vad_model == SILERO:
905
906
  profile.vad.backup_vad_model = OFF
906
907
 
907
- if changed:
908
- self.save()
909
- self.overlay = self.get_config().overlay
910
-
911
- self.version = get_current_version()
908
+ self.save()
912
909
 
913
910
  def save(self):
914
911
  with open(get_config_path(), 'w') as file:
@@ -451,9 +451,9 @@ class AIModelsTable(SQLiteDBTable):
451
451
  class GameLinesTable(SQLiteDBTable):
452
452
  _table = 'game_lines'
453
453
  _fields = ['game_name', 'line_text', 'screenshot_in_anki',
454
- 'audio_in_anki', 'screenshot_path', 'audio_path', 'replay_path', 'translation', 'timestamp', 'original_game_name']
454
+ 'audio_in_anki', 'screenshot_path', 'audio_path', 'replay_path', 'translation', 'timestamp', 'original_game_name', 'game_id']
455
455
  _types = [str, # Includes primary key type
456
- str, str, str, str, str, str, str, str, float, str]
456
+ str, str, str, str, str, str, str, str, float, str, str]
457
457
  _pk = 'id'
458
458
  _auto_increment = False # Use string IDs
459
459
 
@@ -468,7 +468,8 @@ class GameLinesTable(SQLiteDBTable):
468
468
  audio_path: Optional[str] = None,
469
469
  replay_path: Optional[str] = None,
470
470
  translation: Optional[str] = None,
471
- original_game_name: Optional[str] = None):
471
+ original_game_name: Optional[str] = None,
472
+ game_id: Optional[str] = None):
472
473
  self.id = id
473
474
  self.game_name = game_name
474
475
  self.line_text = line_text
@@ -481,6 +482,7 @@ class GameLinesTable(SQLiteDBTable):
481
482
  self.replay_path = replay_path if replay_path is not None else ''
482
483
  self.translation = translation if translation is not None else ''
483
484
  self.original_game_name = original_game_name if original_game_name is not None else ''
485
+ self.game_id = game_id if game_id is not None else ''
484
486
 
485
487
  @classmethod
486
488
  def get_all_lines_for_scene(cls, game_name: str) -> List['GameLinesTable']:
@@ -605,7 +607,7 @@ class StatsRollupTable(SQLiteDBTable):
605
607
  stats.anki_cards_created += anki_cards_created
606
608
  stats.time_spent_mining += time_spent_mining
607
609
  stats.save()
608
-
610
+
609
611
  # Ensure database directory exists and return path
610
612
  def get_db_directory(test=False, delete_test=False) -> str:
611
613
  if platform == 'win32': # Windows
@@ -659,7 +661,10 @@ if os.path.exists(db_path):
659
661
 
660
662
  gsm_db = SQLiteDB(db_path)
661
663
 
662
- for cls in [AIModelsTable, GameLinesTable]:
664
+ # Import GamesTable after gsm_db is created to avoid circular import
665
+ from GameSentenceMiner.util.games_table import GamesTable
666
+
667
+ for cls in [AIModelsTable, GameLinesTable, GamesTable]:
663
668
  cls.set_db(gsm_db)
664
669
  # Uncomment to start fresh every time
665
670
  # cls.drop()
@@ -680,8 +685,7 @@ def check_and_run_migrations():
680
685
  # Copy and cast data from old column to new column
681
686
  GameLinesTable.alter_column_type('timestamp_old', 'timestamp', 'REAL')
682
687
  logger.info("Migrated 'timestamp' column to REAL type in GameLinesTable.")
683
-
684
-
688
+
685
689
  migrate_timestamp()
686
690
 
687
691
  check_and_run_migrations()
@@ -0,0 +1,320 @@
1
+ import uuid
2
+ from typing import Optional, List, Dict
3
+
4
+ from GameSentenceMiner.util.db import SQLiteDBTable
5
+ from GameSentenceMiner.util.configuration import logger
6
+
7
+
8
+ class GamesTable(SQLiteDBTable):
9
+ _table = 'games'
10
+ _fields = [
11
+ 'deck_id', 'title_original', 'title_romaji', 'title_english',
12
+ 'type',
13
+ 'description', 'image', 'character_count', 'difficulty', 'links', 'completed',
14
+ 'manual_overrides'
15
+ ]
16
+ _types = [
17
+ str, # id (primary key)
18
+ int, # deck_id
19
+ str, # title_original
20
+ str, # title_romaji
21
+ str, # title_english
22
+ str, # type (string)
23
+ str, # description
24
+ str, # image (base64)
25
+ int, # character_count
26
+ int, # difficulty
27
+ list, # links (stored as JSON)
28
+ bool, # completed
29
+ list # manual_overrides (stored as JSON)
30
+ ]
31
+ _pk = 'id'
32
+ _auto_increment = False # UUID-based primary key
33
+
34
+ def __init__(
35
+ self,
36
+ id: Optional[str] = None,
37
+ deck_id: Optional[int] = None,
38
+ title_original: Optional[str] = None,
39
+ title_romaji: Optional[str] = None,
40
+ title_english: Optional[str] = None,
41
+ type: Optional[str] = None,
42
+ description: Optional[str] = None,
43
+ image: Optional[str] = None,
44
+ character_count: int = 0,
45
+ difficulty: Optional[int] = None,
46
+ links: Optional[List[Dict]] = None,
47
+ completed: bool = False,
48
+ manual_overrides: Optional[List[str]] = None
49
+ ):
50
+ self.id = id if id else str(uuid.uuid4())
51
+ self.deck_id = deck_id
52
+ self.title_original = title_original if title_original else ''
53
+ self.title_romaji = title_romaji if title_romaji else ''
54
+ self.title_english = title_english if title_english else ''
55
+ self.type = type if type else ''
56
+ self.description = description if description else ''
57
+ self.image = image if image else ''
58
+ self.character_count = character_count
59
+ self.difficulty = difficulty
60
+ self.links = links if links else []
61
+ self.completed = completed
62
+ self.manual_overrides = manual_overrides if manual_overrides else []
63
+
64
+ @classmethod
65
+ def get_by_deck_id(cls, deck_id: int) -> Optional['GamesTable']:
66
+ """Get a game by its jiten.moe deck ID."""
67
+ row = cls._db.fetchone(
68
+ f"SELECT * FROM {cls._table} WHERE deck_id=?", (deck_id,))
69
+ return cls.from_row(row) if row else None
70
+
71
+ @classmethod
72
+ def get_by_title(cls, title_original: str) -> Optional['GamesTable']:
73
+ """Get a game by its original title."""
74
+ row = cls._db.fetchone(
75
+ f"SELECT * FROM {cls._table} WHERE title_original=?", (title_original,))
76
+ return cls.from_row(row) if row else None
77
+
78
+ @classmethod
79
+ def get_or_create_by_name(cls, game_name: str) -> 'GamesTable':
80
+ """
81
+ Get an existing game by name, or create a new one if it doesn't exist.
82
+ This is the primary method for automatically linking game_lines to games.
83
+
84
+ Args:
85
+ game_name: The original game name (from game_lines.game_name)
86
+
87
+ Returns:
88
+ GamesTable: The existing or newly created game record
89
+ """
90
+ # Try to find existing game
91
+ existing = cls.get_by_title(game_name)
92
+ if existing:
93
+ return existing
94
+
95
+ # Create new game with minimal info
96
+ new_game = cls(
97
+ title_original=game_name,
98
+ title_romaji='',
99
+ title_english='',
100
+ description='',
101
+ difficulty=None,
102
+ completed=False
103
+ )
104
+ new_game.save()
105
+ logger.info(f"Auto-created new game record: {game_name} (id={new_game.id})")
106
+ return new_game
107
+
108
+ @classmethod
109
+ def get_all_completed(cls) -> List['GamesTable']:
110
+ """Get all completed games."""
111
+ rows = cls._db.fetchall(
112
+ f"SELECT * FROM {cls._table} WHERE completed=1")
113
+ return [cls.from_row(row) for row in rows]
114
+
115
+ @classmethod
116
+ def get_all_in_progress(cls) -> List['GamesTable']:
117
+ """Get all games that are in progress (not completed)."""
118
+ rows = cls._db.fetchall(
119
+ f"SELECT * FROM {cls._table} WHERE completed=0")
120
+ return [cls.from_row(row) for row in rows]
121
+
122
+ @classmethod
123
+ def get_start_date(cls, game_id: str) -> Optional[float]:
124
+ """
125
+ Get the start date (timestamp of first line) for a game.
126
+ Returns Unix timestamp (float) or None if no lines exist.
127
+ """
128
+ from GameSentenceMiner.util.db import GameLinesTable
129
+ result = GameLinesTable._db.fetchone(
130
+ f"SELECT MIN(timestamp) FROM {GameLinesTable._table} WHERE game_id=?",
131
+ (game_id,)
132
+ )
133
+ return result[0] if result and result[0] else None
134
+
135
+ @classmethod
136
+ def get_last_played_date(cls, game_id: str) -> Optional[float]:
137
+ """
138
+ Get the last played date (timestamp of most recent line) for a game.
139
+ Returns Unix timestamp (float) or None if no lines exist.
140
+ """
141
+ from GameSentenceMiner.util.db import GameLinesTable
142
+ result = GameLinesTable._db.fetchone(
143
+ f"SELECT MAX(timestamp) FROM {GameLinesTable._table} WHERE game_id=?",
144
+ (game_id,)
145
+ )
146
+ return result[0] if result and result[0] else None
147
+
148
+ def is_field_manual(self, field_name: str) -> bool:
149
+ """
150
+ Check if a field has been manually edited and should not be auto-updated.
151
+
152
+ Args:
153
+ field_name: The name of the field to check
154
+
155
+ Returns:
156
+ True if the field is manually overridden, False otherwise
157
+ """
158
+ return field_name in self.manual_overrides
159
+
160
+ def mark_field_manual(self, field_name: str):
161
+ """
162
+ Mark a field as manually edited so it won't be auto-updated.
163
+
164
+ Args:
165
+ field_name: The name of the field to mark as manual
166
+ """
167
+ if field_name not in self.manual_overrides and field_name in self._fields:
168
+ self.manual_overrides.append(field_name)
169
+ logger.debug(f"Marked field '{field_name}' as manually overridden for game {self.id}")
170
+
171
+ def update_all_fields_manual(
172
+ self,
173
+ deck_id: Optional[int] = None,
174
+ title_original: Optional[str] = None,
175
+ title_romaji: Optional[str] = None,
176
+ title_english: Optional[str] = None,
177
+ type: Optional[str] = None,
178
+ description: Optional[str] = None,
179
+ image: Optional[str] = None,
180
+ character_count: Optional[int] = None,
181
+ difficulty: Optional[int] = None,
182
+ links: Optional[List[Dict]] = None,
183
+ completed: Optional[bool] = None
184
+ ):
185
+ """
186
+ Update all fields of the game at once. Only provided fields will be updated.
187
+ Fields that are updated will be automatically marked as manually overridden.
188
+
189
+ Args:
190
+ deck_id: jiten.moe deck ID
191
+ title_original: Original Japanese title
192
+ title_romaji: Romanized title
193
+ title_english: English translated title
194
+ description: Game description
195
+ image: Base64-encoded image data
196
+ character_count: Total character count
197
+ difficulty: Difficulty rating
198
+ links: List of link objects
199
+ completed: Whether the game is completed
200
+ """
201
+ if deck_id is not None:
202
+ self.deck_id = deck_id
203
+ self.mark_field_manual('deck_id')
204
+ if title_original is not None:
205
+ self.title_original = title_original
206
+ self.mark_field_manual('title_original')
207
+ if title_romaji is not None:
208
+ self.title_romaji = title_romaji
209
+ self.mark_field_manual('title_romaji')
210
+ if title_english is not None:
211
+ self.title_english = title_english
212
+ self.mark_field_manual('title_english')
213
+ if type is not None:
214
+ self.type = type
215
+ self.mark_field_manual('type')
216
+ if description is not None:
217
+ self.description = description
218
+ self.mark_field_manual('description')
219
+ if image is not None:
220
+ self.image = image
221
+ self.mark_field_manual('image')
222
+ if character_count is not None:
223
+ self.character_count = character_count
224
+ self.mark_field_manual('character_count')
225
+ if difficulty is not None:
226
+ self.difficulty = difficulty
227
+ self.mark_field_manual('difficulty')
228
+ if links is not None:
229
+ self.links = links
230
+ self.mark_field_manual('links')
231
+ if completed is not None:
232
+ self.completed = completed
233
+ self.mark_field_manual('completed')
234
+
235
+ self.save()
236
+ logger.info(f"Updated game {self.id} ({self.title_original})")
237
+
238
+ def update_all_fields_from_jiten(
239
+ self,
240
+ deck_id: Optional[int] = None,
241
+ title_original: Optional[str] = None,
242
+ title_romaji: Optional[str] = None,
243
+ title_english: Optional[str] = None,
244
+ type: Optional[str] = None,
245
+ description: Optional[str] = None,
246
+ image: Optional[str] = None,
247
+ character_count: Optional[int] = None,
248
+ difficulty: Optional[int] = None,
249
+ links: Optional[List[Dict]] = None,
250
+ completed: Optional[bool] = None
251
+ ):
252
+ """
253
+ Update all fields of the game at once. Only provided fields will be updated.
254
+
255
+ Args:
256
+ deck_id: jiten.moe deck ID
257
+ title_original: Original Japanese title
258
+ title_romaji: Romanized title
259
+ title_english: English translated title
260
+ description: Game description
261
+ image: Base64-encoded image data
262
+ character_count: Total character count
263
+ difficulty: Difficulty rating
264
+ links: List of link objects
265
+ completed: Whether the game is completed
266
+ """
267
+ if deck_id is not None:
268
+ self.deck_id = deck_id
269
+ if title_original is not None:
270
+ self.title_original = title_original
271
+ if title_romaji is not None:
272
+ self.title_romaji = title_romaji
273
+ if title_english is not None:
274
+ self.title_english = title_english
275
+ if type is not None:
276
+ self.type = type
277
+ if description is not None:
278
+ self.description = description
279
+ if image is not None:
280
+ self.image = image
281
+ if character_count is not None:
282
+ self.character_count = character_count
283
+ if difficulty is not None:
284
+ self.difficulty = difficulty
285
+ if links is not None:
286
+ self.links = links
287
+ if completed is not None:
288
+ self.completed = completed
289
+ self.save()
290
+ logger.info(f"Updated game {self.id} ({self.title_original})")
291
+
292
+ def add_link(self, link_type: int, url: str, link_id: Optional[int] = None):
293
+ """
294
+ Add a link to the game's links array and persist to database.
295
+
296
+ Args:
297
+ link_type: Type of link (e.g., 4 for AniList, 5 for MyAnimeList)
298
+ url: URL of the link
299
+ link_id: Optional link ID
300
+
301
+ Note:
302
+ Changes are automatically saved to the database.
303
+ """
304
+ new_link = {
305
+ 'linkType': link_type,
306
+ 'url': url,
307
+ 'deckId': self.deck_id
308
+ }
309
+ if link_id is not None:
310
+ new_link['linkId'] = link_id
311
+
312
+ self.links.append(new_link)
313
+ self.save()
314
+
315
+ def get_lines(self) -> List:
316
+ """Get all lines associated with this game."""
317
+ from GameSentenceMiner.util.db import GameLinesTable
318
+ rows = GameLinesTable._db.fetchall(
319
+ f"SELECT * FROM {GameLinesTable._table} WHERE game_id=?", (self.id,))
320
+ return [GameLinesTable.from_row(row) for row in rows]