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.
- GameSentenceMiner/anki.py +8 -53
- GameSentenceMiner/owocr/owocr/ocr.py +3 -2
- GameSentenceMiner/owocr/owocr/run.py +5 -1
- GameSentenceMiner/ui/anki_confirmation.py +16 -2
- GameSentenceMiner/util/configuration.py +6 -9
- GameSentenceMiner/util/db.py +11 -7
- GameSentenceMiner/util/games_table.py +320 -0
- GameSentenceMiner/web/anki_api_endpoints.py +506 -0
- GameSentenceMiner/web/database_api.py +239 -117
- GameSentenceMiner/web/static/css/loading-skeleton.css +41 -0
- GameSentenceMiner/web/static/css/search.css +54 -0
- GameSentenceMiner/web/static/css/stats.css +76 -0
- GameSentenceMiner/web/static/js/anki_stats.js +304 -50
- GameSentenceMiner/web/static/js/database.js +44 -7
- GameSentenceMiner/web/static/js/heatmap.js +326 -0
- GameSentenceMiner/web/static/js/overview.js +20 -224
- GameSentenceMiner/web/static/js/search.js +190 -23
- GameSentenceMiner/web/static/js/stats.js +371 -1
- GameSentenceMiner/web/stats.py +188 -0
- GameSentenceMiner/web/templates/anki_stats.html +145 -58
- GameSentenceMiner/web/templates/components/date-range.html +19 -0
- GameSentenceMiner/web/templates/components/html-head.html +45 -0
- GameSentenceMiner/web/templates/components/js-config.html +37 -0
- GameSentenceMiner/web/templates/components/popups.html +15 -0
- GameSentenceMiner/web/templates/components/settings-modal.html +233 -0
- GameSentenceMiner/web/templates/database.html +13 -3
- GameSentenceMiner/web/templates/goals.html +9 -31
- GameSentenceMiner/web/templates/overview.html +16 -223
- GameSentenceMiner/web/templates/search.html +46 -0
- GameSentenceMiner/web/templates/stats.html +49 -311
- GameSentenceMiner/web/texthooking_page.py +4 -66
- {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/RECORD +37 -28
- {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.17.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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()) * '
|
|
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
|
-
|
|
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
|
-
|
|
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} != {
|
|
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
|
-
|
|
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:
|
GameSentenceMiner/util/db.py
CHANGED
|
@@ -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
|
-
|
|
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]
|