GameSentenceMiner 2.16.4__py3-none-any.whl → 2.16.6__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 +7 -2
- GameSentenceMiner/config_gui.py +12 -0
- GameSentenceMiner/locales/en_us.json +5 -1
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/util/configuration.py +35 -0
- GameSentenceMiner/util/ffmpeg.py +3 -3
- GameSentenceMiner/web/database_api.py +203 -76
- GameSentenceMiner/web/static/css/stats.css +243 -0
- GameSentenceMiner/web/static/js/search.js +46 -22
- GameSentenceMiner/web/static/js/shared.js +60 -12
- GameSentenceMiner/web/static/js/stats.js +363 -20
- GameSentenceMiner/web/stats.py +3 -3
- GameSentenceMiner/web/templates/search.html +6 -1
- GameSentenceMiner/web/templates/stats.html +163 -12
- GameSentenceMiner/web/texthooking_page.py +1 -1
- {gamesentenceminer-2.16.4.dist-info → gamesentenceminer-2.16.6.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.16.4.dist-info → gamesentenceminer-2.16.6.dist-info}/RECORD +23 -23
- /GameSentenceMiner/web/{websockets.py → gsm_websocket.py} +0 -0
- {gamesentenceminer-2.16.4.dist-info → gamesentenceminer-2.16.6.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.16.4.dist-info → gamesentenceminer-2.16.6.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.16.4.dist-info → gamesentenceminer-2.16.6.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.16.4.dist-info → gamesentenceminer-2.16.6.dist-info}/top_level.txt +0 -0
GameSentenceMiner/anki.py
CHANGED
@@ -87,13 +87,18 @@ def update_anki_card(last_note: AnkiCard, note=None, audio_path='', video_path='
|
|
87
87
|
|
88
88
|
if update_picture and screenshot_in_anki:
|
89
89
|
note['fields'][get_config().anki.picture_field] = image_html
|
90
|
-
|
90
|
+
|
91
91
|
if video_in_anki:
|
92
92
|
note['fields'][get_config().anki.video_field] = video_in_anki
|
93
|
-
|
93
|
+
|
94
94
|
if not get_config().screenshot.enabled:
|
95
95
|
logger.info("Skipping Adding Screenshot to Anki, Screenshot is disabled in settings")
|
96
96
|
|
97
|
+
# Add game name to field if configured
|
98
|
+
game_name_field = get_config().anki.game_name_field
|
99
|
+
if note and 'fields' in note and game_name_field:
|
100
|
+
note['fields'][game_name_field] = get_current_game()
|
101
|
+
|
97
102
|
if note and 'fields' in note and get_config().ai.enabled:
|
98
103
|
sentence_field = note['fields'].get(get_config().anki.sentence_field, {})
|
99
104
|
sentence_to_translate = sentence_field if sentence_field else last_note.get_field(
|
GameSentenceMiner/config_gui.py
CHANGED
@@ -307,6 +307,7 @@ class ConfigApp:
|
|
307
307
|
self.word_field_value = tk.StringVar(value=self.settings.anki.word_field)
|
308
308
|
self.previous_sentence_field_value = tk.StringVar(value=self.settings.anki.previous_sentence_field)
|
309
309
|
self.previous_image_field_value = tk.StringVar(value=self.settings.anki.previous_image_field)
|
310
|
+
self.game_name_field_value = tk.StringVar(value=self.settings.anki.game_name_field)
|
310
311
|
self.video_field_value = tk.StringVar(value=self.settings.anki.video_field)
|
311
312
|
self.custom_tags_value = tk.StringVar(value=', '.join(self.settings.anki.custom_tags))
|
312
313
|
self.tags_to_check_value = tk.StringVar(value=', '.join(self.settings.anki.tags_to_check))
|
@@ -440,6 +441,7 @@ class ConfigApp:
|
|
440
441
|
default_category_config = getattr(self.default_settings, category)
|
441
442
|
|
442
443
|
setattr(self.settings, category, default_category_config)
|
444
|
+
self.create_vars() # Recreate variables to reflect default values
|
443
445
|
recreate_tab()
|
444
446
|
self.save_settings(profile_change=False)
|
445
447
|
self.reload_settings()
|
@@ -527,6 +529,7 @@ class ConfigApp:
|
|
527
529
|
previous_sentence_field=self.previous_sentence_field_value.get(),
|
528
530
|
previous_image_field=self.previous_image_field_value.get(),
|
529
531
|
video_field=self.video_field_value.get(),
|
532
|
+
game_name_field=self.game_name_field_value.get(),
|
530
533
|
custom_tags=[tag.strip() for tag in self.custom_tags_value.get().split(',') if tag.strip()],
|
531
534
|
tags_to_check=[tag.strip().lower() for tag in self.tags_to_check_value.get().split(',') if tag.strip()],
|
532
535
|
add_game_tag=self.add_game_tag_value.get(),
|
@@ -1146,6 +1149,9 @@ class ConfigApp:
|
|
1146
1149
|
HoverInfoLabelWidget(vad_frame, text=use_cpu_i18n.get('label', 'Force CPU'), tooltip=use_cpu_i18n.get('tooltip', 'Even if CUDA is installed, use CPU for Whisper'), row=self.current_row, column=0)
|
1147
1150
|
ttk.Checkbutton(vad_frame, variable=self.use_cpu_for_inference_value, bootstyle="round-toggle").grid(row=self.current_row, column=1, sticky='W', pady=2)
|
1148
1151
|
self.current_row += 1
|
1152
|
+
|
1153
|
+
# Add Reset Button
|
1154
|
+
self.add_reset_button(vad_frame, "vad", self.current_row, column=0, recreate_tab=self.create_vad_tab)
|
1149
1155
|
|
1150
1156
|
@new_tab
|
1151
1157
|
def create_paths_tab(self):
|
@@ -1319,6 +1325,12 @@ class ConfigApp:
|
|
1319
1325
|
row=self.current_row, column=0)
|
1320
1326
|
ttk.Entry(anki_frame, textvariable=self.video_field_value).grid(row=self.current_row, column=1, sticky='EW', pady=2)
|
1321
1327
|
self.current_row += 1
|
1328
|
+
|
1329
|
+
game_name_field_i18n = anki_i18n.get('game_name_field', {})
|
1330
|
+
HoverInfoLabelWidget(anki_frame, text=game_name_field_i18n.get('label', 'Game Name Field:'),
|
1331
|
+
tooltip=game_name_field_i18n.get('tooltip', 'Field in Anki for the game name.'), row=self.current_row, column=0)
|
1332
|
+
ttk.Entry(anki_frame, textvariable=self.game_name_field_value).grid(row=self.current_row, column=1, columnspan=3, sticky='EW', pady=2)
|
1333
|
+
self.current_row += 1
|
1322
1334
|
|
1323
1335
|
tags_i18n = anki_i18n.get('custom_tags', {})
|
1324
1336
|
HoverInfoLabelWidget(anki_frame, text=tags_i18n.get('label', '...'), tooltip=tags_i18n.get('tooltip', '...'),
|
@@ -184,12 +184,16 @@
|
|
184
184
|
},
|
185
185
|
"video_field": {
|
186
186
|
"label": "Video Field:",
|
187
|
-
"tooltip": "Field in Anki for associated videos. This will be AV1 encoded video of the VAD Trimmed Voiceline, if no Voice found, this will be empty."
|
187
|
+
"tooltip": "Field in Anki for associated videos. This will be AV1 encoded video of the VAD Trimmed Voiceline, if no Voice found, this will be empty. (OPTIONAL)"
|
188
188
|
},
|
189
189
|
"custom_tags": {
|
190
190
|
"label": "Add Tags:",
|
191
191
|
"tooltip": "Comma-separated custom tags for the Anki cards."
|
192
192
|
},
|
193
|
+
"game_name_field": {
|
194
|
+
"label": "Game Name Field:",
|
195
|
+
"tooltip": "Field in Anki for the game name. If empty, game name will not be added as a field. (OPTIONAL)"
|
196
|
+
},
|
193
197
|
"tags_to_check": {
|
194
198
|
"label": "Tags to work on:",
|
195
199
|
"tooltip": "Comma-separated Tags, script will only do 1-click on cards with these tags (Recommend keep empty, or use Yomitan Profile to add custom tag from texthooker page)"
|
@@ -189,6 +189,10 @@
|
|
189
189
|
"label": "追加タグ:",
|
190
190
|
"tooltip": "Ankiカードに追加するカスタムタグ(カンマ区切り)。"
|
191
191
|
},
|
192
|
+
"game_name_field": {
|
193
|
+
"label": "ゲーム名フィールド:",
|
194
|
+
"tooltip": "Ankiのゲーム名用フィールド。空欄の場合は追加されません。"
|
195
|
+
},
|
192
196
|
"tags_to_check": {
|
193
197
|
"label": "対象タグ:",
|
194
198
|
"tooltip": "これらのタグを持つカードのみワンクリック対象になります(通常は空を推奨)。"
|
@@ -190,6 +190,10 @@
|
|
190
190
|
"label": "添加标签:",
|
191
191
|
"tooltip": "Anki 卡片的自定义标签(以逗号分隔)。"
|
192
192
|
},
|
193
|
+
"game_name_field": {
|
194
|
+
"label": "游戏名称字段:",
|
195
|
+
"tooltip": "Anki 中用于游戏名称的字段。如果为空,则不会添加游戏名称。"
|
196
|
+
},
|
193
197
|
"tags_to_check": {
|
194
198
|
"label": "处理的标签:",
|
195
199
|
"tooltip": "脚本将只对带有这些标签的卡片进行一键操作(建议留空)。"
|
@@ -441,6 +441,7 @@ class Anki:
|
|
441
441
|
custom_tags: List[str] = None
|
442
442
|
tags_to_check: List[str] = None
|
443
443
|
add_game_tag: bool = True
|
444
|
+
game_name_field: str = ''
|
444
445
|
polling_rate: int = 200
|
445
446
|
overwrite_audio: bool = False
|
446
447
|
overwrite_picture: bool = True
|
@@ -780,6 +781,15 @@ class ProfileConfig:
|
|
780
781
|
def config_changed(self, new: 'ProfileConfig') -> bool:
|
781
782
|
return self != new
|
782
783
|
|
784
|
+
@dataclass_json
|
785
|
+
@dataclass
|
786
|
+
class StatsConfig:
|
787
|
+
afk_timer_seconds: int = 120
|
788
|
+
session_gap_seconds: int = 3600
|
789
|
+
streak_requirement_hours: float = 0.01 # 1 second required per day to keep your streak by default
|
790
|
+
reading_hours_target: int = 1500 # Target reading hours based on TMW N1 achievement data
|
791
|
+
character_count_target: int = 25000000 # Target character count (25M) inspired by Discord server milestones
|
792
|
+
games_target: int = 100 # Target VNs/games completed based on Refold community standards
|
783
793
|
|
784
794
|
@dataclass_json
|
785
795
|
@dataclass
|
@@ -788,6 +798,7 @@ class Config:
|
|
788
798
|
current_profile: str = DEFAULT_CONFIG
|
789
799
|
switch_to_default_if_not_found: bool = True
|
790
800
|
locale: str = Locale.English.value
|
801
|
+
stats: StatsConfig = field(default_factory=StatsConfig)
|
791
802
|
|
792
803
|
@classmethod
|
793
804
|
def new(cls):
|
@@ -812,6 +823,18 @@ class Config:
|
|
812
823
|
return cls.from_dict(data)
|
813
824
|
else:
|
814
825
|
return cls.new()
|
826
|
+
|
827
|
+
def __post_init__(self):
|
828
|
+
# Move Stats to global config if found in profiles for legacy support
|
829
|
+
default_stats = StatsConfig()
|
830
|
+
for profile in self.configs.values():
|
831
|
+
if profile.advanced:
|
832
|
+
if profile.advanced.afk_timer_seconds != default_stats.afk_timer_seconds:
|
833
|
+
self.stats.afk_timer_seconds = profile.advanced.afk_timer_seconds
|
834
|
+
if profile.advanced.session_gap_seconds != default_stats.session_gap_seconds:
|
835
|
+
self.stats.session_gap_seconds = profile.advanced.session_gap_seconds
|
836
|
+
if profile.advanced.streak_requirement_hours != default_stats.streak_requirement_hours:
|
837
|
+
self.stats.streak_requirement_hours = profile.advanced.streak_requirement_hours
|
815
838
|
|
816
839
|
def save(self):
|
817
840
|
with open(get_config_path(), 'w') as file:
|
@@ -1069,6 +1092,12 @@ def reload_config():
|
|
1069
1092
|
logger.warning(
|
1070
1093
|
"Backfill is enabled, but full auto is also enabled. Disabling backfill...")
|
1071
1094
|
config.features.backfill_audio = False
|
1095
|
+
|
1096
|
+
def get_stats_config():
|
1097
|
+
global config_instance
|
1098
|
+
if config_instance is None:
|
1099
|
+
config_instance = load_config()
|
1100
|
+
return config_instance.stats
|
1072
1101
|
|
1073
1102
|
|
1074
1103
|
def get_master_config():
|
@@ -1085,6 +1114,12 @@ def save_current_config(config):
|
|
1085
1114
|
config_instance.set_config_for_profile(
|
1086
1115
|
config_instance.current_profile, config)
|
1087
1116
|
save_full_config(config_instance)
|
1117
|
+
|
1118
|
+
|
1119
|
+
def save_stats_config(stats_config):
|
1120
|
+
global config_instance
|
1121
|
+
config_instance.stats = stats_config
|
1122
|
+
save_full_config(config_instance)
|
1088
1123
|
|
1089
1124
|
|
1090
1125
|
def switch_profile_and_save(profile_name):
|
GameSentenceMiner/util/ffmpeg.py
CHANGED
@@ -453,7 +453,7 @@ def trim_audio_based_on_last_line(untrimmed_audio, video_path, game_line, next_l
|
|
453
453
|
ffmpeg_command = ffmpeg_base_command_list + [
|
454
454
|
"-i", untrimmed_audio,
|
455
455
|
"-ss", str(start_trim_time)]
|
456
|
-
if next_line and next_line > game_line.time:
|
456
|
+
if next_line and next_line > game_line.time and total_seconds:
|
457
457
|
end_trim_seconds = total_seconds + (next_line - game_line.time).total_seconds() + get_config().audio.pre_vad_end_offset
|
458
458
|
end_trim_time = f"{end_trim_seconds:.3f}"
|
459
459
|
ffmpeg_command.extend(['-to', end_trim_time])
|
@@ -495,9 +495,9 @@ def get_video_timings(video_path, game_line, anki_card_creation_time=None):
|
|
495
495
|
total_seconds = file_length - time_delta.total_seconds()
|
496
496
|
total_seconds_after_offset = total_seconds + get_config().audio.beginning_offset
|
497
497
|
if total_seconds < 0 or total_seconds >= file_length:
|
498
|
-
logger.error("Line mined is outside of the replay buffer! Defaulting to the
|
498
|
+
logger.error("Line mined is outside of the replay buffer! Defaulting to the last 30 seconds of the replay buffer.")
|
499
499
|
logger.info("Recommend either increasing replay buffer length in OBS Settings or mining faster.")
|
500
|
-
return 0, 0, 0, file_length
|
500
|
+
return max(file_length - 30, 0), 0, max(file_length - 30, 0), file_length
|
501
501
|
|
502
502
|
return total_seconds_after_offset, total_seconds, total_seconds_after_offset, file_length
|
503
503
|
|
@@ -1,14 +1,17 @@
|
|
1
|
+
import copy
|
1
2
|
import datetime
|
2
3
|
import re
|
3
4
|
import csv
|
4
5
|
import io
|
5
6
|
from collections import defaultdict
|
7
|
+
import time
|
6
8
|
|
7
9
|
import flask
|
8
10
|
from flask import request, jsonify
|
11
|
+
import regex
|
9
12
|
|
10
13
|
from GameSentenceMiner.util.db import GameLinesTable
|
11
|
-
from GameSentenceMiner.util.configuration import logger, get_config, save_current_config
|
14
|
+
from GameSentenceMiner.util.configuration import get_stats_config, logger, get_config, save_current_config, save_stats_config
|
12
15
|
from GameSentenceMiner.web.stats import (
|
13
16
|
calculate_kanji_frequency, calculate_heatmap_data, calculate_total_chars_per_game,
|
14
17
|
calculate_reading_time_per_game, calculate_reading_speed_per_game,
|
@@ -32,6 +35,7 @@ def register_database_api_routes(app):
|
|
32
35
|
sort_by = request.args.get('sort', 'relevance')
|
33
36
|
page = int(request.args.get('page', 1))
|
34
37
|
page_size = int(request.args.get('page_size', 20))
|
38
|
+
use_regex = request.args.get('use_regex', 'false').lower() == 'true'
|
35
39
|
|
36
40
|
# Validate parameters
|
37
41
|
if not query:
|
@@ -41,65 +45,129 @@ def register_database_api_routes(app):
|
|
41
45
|
page = 1
|
42
46
|
if page_size < 1 or page_size > 100:
|
43
47
|
page_size = 20
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
48
|
+
|
49
|
+
if use_regex:
|
50
|
+
# Regex search: fetch all candidate rows, filter in Python
|
51
|
+
try:
|
52
|
+
# Ensure query is a string
|
53
|
+
if not isinstance(query, str):
|
54
|
+
return jsonify({'error': 'Invalid query parameter type'}), 400
|
55
|
+
|
56
|
+
all_lines = GameLinesTable.all()
|
57
|
+
if game_filter:
|
58
|
+
all_lines = [line for line in all_lines if line.game_name == game_filter]
|
59
|
+
|
60
|
+
# Compile regex pattern with proper error handling
|
61
|
+
try:
|
62
|
+
pattern = re.compile(query, re.IGNORECASE)
|
63
|
+
except re.error as regex_err:
|
64
|
+
return jsonify({'error': f'Invalid regex pattern: {str(regex_err)}'}), 400
|
65
|
+
|
66
|
+
# Filter lines using regex
|
67
|
+
filtered_lines = []
|
68
|
+
for line in all_lines:
|
69
|
+
if line.line_text and isinstance(line.line_text, str):
|
70
|
+
try:
|
71
|
+
if pattern.search(line.line_text):
|
72
|
+
filtered_lines.append(line)
|
73
|
+
except Exception as search_err:
|
74
|
+
# Log but continue with other lines
|
75
|
+
logger.warning(f"Regex search error on line {line.id}: {search_err}")
|
76
|
+
continue
|
77
|
+
|
78
|
+
# Sorting (default: timestamp DESC, or as specified)
|
79
|
+
if sort_by == 'date_asc':
|
80
|
+
filtered_lines.sort(key=lambda l: float(l.timestamp) if l.timestamp else 0)
|
81
|
+
elif sort_by == 'game_name':
|
82
|
+
filtered_lines.sort(key=lambda l: (l.game_name or '', -(float(l.timestamp) if l.timestamp else 0)))
|
83
|
+
else: # date_desc or relevance
|
84
|
+
filtered_lines.sort(key=lambda l: -(float(l.timestamp) if l.timestamp else 0))
|
85
|
+
|
86
|
+
total_results = len(filtered_lines)
|
87
|
+
# Pagination
|
88
|
+
start = (page - 1) * page_size
|
89
|
+
end = start + page_size
|
90
|
+
paged_lines = filtered_lines[start:end]
|
91
|
+
results = []
|
92
|
+
for line in paged_lines:
|
93
|
+
results.append({
|
94
|
+
'id': line.id,
|
95
|
+
'sentence': line.line_text or '',
|
96
|
+
'game_name': line.game_name or 'Unknown Game',
|
97
|
+
'timestamp': float(line.timestamp) if line.timestamp else 0,
|
98
|
+
'translation': line.translation or None,
|
99
|
+
'has_audio': bool(getattr(line, 'audio_path', None)),
|
100
|
+
'has_screenshot': bool(getattr(line, 'screenshot_path', None))
|
101
|
+
})
|
102
|
+
return jsonify({
|
103
|
+
'results': results,
|
104
|
+
'total': total_results,
|
105
|
+
'page': page,
|
106
|
+
'page_size': page_size,
|
107
|
+
'total_pages': (total_results + page_size - 1) // page_size
|
108
|
+
}), 200
|
109
|
+
except Exception as e:
|
110
|
+
logger.error(f"Regex search failed: {e}")
|
111
|
+
return jsonify({'error': f'Search failed: {str(e)}'}), 500
|
112
|
+
else:
|
113
|
+
# Build the SQL query
|
114
|
+
base_query = f"SELECT * FROM {GameLinesTable._table} WHERE line_text LIKE ?"
|
115
|
+
params = [f'%{query}%']
|
116
|
+
|
117
|
+
# Add game filter if specified
|
118
|
+
if game_filter:
|
119
|
+
base_query += " AND game_name = ?"
|
120
|
+
params.append(game_filter)
|
121
|
+
|
122
|
+
# Add sorting
|
123
|
+
if sort_by == 'date_desc':
|
124
|
+
base_query += " ORDER BY timestamp DESC"
|
125
|
+
elif sort_by == 'date_asc':
|
126
|
+
base_query += " ORDER BY timestamp ASC"
|
127
|
+
elif sort_by == 'game_name':
|
128
|
+
base_query += " ORDER BY game_name, timestamp DESC"
|
129
|
+
else: # relevance - could be enhanced with proper scoring
|
130
|
+
base_query += " ORDER BY timestamp DESC"
|
131
|
+
|
132
|
+
# Get total count for pagination
|
133
|
+
count_query = f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE line_text LIKE ?"
|
134
|
+
count_params = [f'%{query}%']
|
135
|
+
if game_filter:
|
136
|
+
count_query += " AND game_name = ?"
|
137
|
+
count_params.append(game_filter)
|
138
|
+
|
139
|
+
total_results = GameLinesTable._db.fetchone(count_query, count_params)[0]
|
140
|
+
|
141
|
+
# Add pagination
|
142
|
+
offset = (page - 1) * page_size
|
143
|
+
base_query += f" LIMIT ? OFFSET ?"
|
144
|
+
params.extend([page_size, offset])
|
145
|
+
|
146
|
+
# Execute search query
|
147
|
+
rows = GameLinesTable._db.fetchall(base_query, params)
|
148
|
+
|
149
|
+
# Format results
|
150
|
+
results = []
|
151
|
+
for row in rows:
|
152
|
+
game_line = GameLinesTable.from_row(row)
|
153
|
+
if game_line:
|
154
|
+
results.append({
|
155
|
+
'id': game_line.id,
|
156
|
+
'sentence': game_line.line_text or '',
|
157
|
+
'game_name': game_line.game_name or 'Unknown Game',
|
158
|
+
'timestamp': float(game_line.timestamp) if game_line.timestamp else 0,
|
159
|
+
'translation': game_line.translation or None,
|
160
|
+
'has_audio': bool(game_line.audio_path),
|
161
|
+
'has_screenshot': bool(game_line.screenshot_path)
|
162
|
+
})
|
163
|
+
|
164
|
+
return jsonify({
|
165
|
+
'results': results,
|
166
|
+
'total': total_results,
|
167
|
+
'page': page,
|
168
|
+
'page_size': page_size,
|
169
|
+
'total_pages': (total_results + page_size - 1) // page_size
|
170
|
+
}), 200
|
103
171
|
|
104
172
|
except ValueError as e:
|
105
173
|
return jsonify({'error': 'Invalid pagination parameters'}), 400
|
@@ -226,14 +294,17 @@ def register_database_api_routes(app):
|
|
226
294
|
@app.route('/api/settings', methods=['GET'])
|
227
295
|
def api_get_settings():
|
228
296
|
"""
|
229
|
-
Get current AFK timer, session gap,
|
297
|
+
Get current AFK timer, session gap, streak requirement, and goal settings.
|
230
298
|
"""
|
231
299
|
try:
|
232
|
-
config =
|
300
|
+
config = get_stats_config()
|
233
301
|
return jsonify({
|
234
|
-
'afk_timer_seconds': config.
|
235
|
-
'session_gap_seconds': config.
|
236
|
-
'streak_requirement_hours':
|
302
|
+
'afk_timer_seconds': config.afk_timer_seconds,
|
303
|
+
'session_gap_seconds': config.session_gap_seconds,
|
304
|
+
'streak_requirement_hours': config.streak_requirement_hours,
|
305
|
+
'reading_hours_target': config.reading_hours_target,
|
306
|
+
'character_count_target': config.character_count_target,
|
307
|
+
'games_target': config.games_target
|
237
308
|
}), 200
|
238
309
|
except Exception as e:
|
239
310
|
logger.error(f"Error getting settings: {e}")
|
@@ -242,7 +313,7 @@ def register_database_api_routes(app):
|
|
242
313
|
@app.route('/api/settings', methods=['POST'])
|
243
314
|
def api_save_settings():
|
244
315
|
"""
|
245
|
-
Save/update AFK timer, session gap,
|
316
|
+
Save/update AFK timer, session gap, streak requirement, and goal settings.
|
246
317
|
"""
|
247
318
|
try:
|
248
319
|
data = request.get_json()
|
@@ -253,6 +324,9 @@ def register_database_api_routes(app):
|
|
253
324
|
afk_timer = data.get('afk_timer_seconds')
|
254
325
|
session_gap = data.get('session_gap_seconds')
|
255
326
|
streak_requirement = data.get('streak_requirement_hours')
|
327
|
+
reading_hours_target = data.get('reading_hours_target')
|
328
|
+
character_count_target = data.get('character_count_target')
|
329
|
+
games_target = data.get('games_target')
|
256
330
|
|
257
331
|
# Validate input - only require the settings that are provided
|
258
332
|
settings_to_update = {}
|
@@ -284,22 +358,54 @@ def register_database_api_routes(app):
|
|
284
358
|
except (ValueError, TypeError):
|
285
359
|
return jsonify({'error': 'Streak requirement must be a valid number'}), 400
|
286
360
|
|
361
|
+
if reading_hours_target is not None:
|
362
|
+
try:
|
363
|
+
reading_hours_target = int(reading_hours_target)
|
364
|
+
if reading_hours_target < 1 or reading_hours_target > 10000:
|
365
|
+
return jsonify({'error': 'Reading hours target must be between 1 and 10,000 hours'}), 400
|
366
|
+
settings_to_update['reading_hours_target'] = reading_hours_target
|
367
|
+
except (ValueError, TypeError):
|
368
|
+
return jsonify({'error': 'Reading hours target must be a valid integer'}), 400
|
369
|
+
|
370
|
+
if character_count_target is not None:
|
371
|
+
try:
|
372
|
+
character_count_target = int(character_count_target)
|
373
|
+
if character_count_target < 1000 or character_count_target > 1000000000:
|
374
|
+
return jsonify({'error': 'Character count target must be between 1,000 and 1,000,000,000 characters'}), 400
|
375
|
+
settings_to_update['character_count_target'] = character_count_target
|
376
|
+
except (ValueError, TypeError):
|
377
|
+
return jsonify({'error': 'Character count target must be a valid integer'}), 400
|
378
|
+
|
379
|
+
if games_target is not None:
|
380
|
+
try:
|
381
|
+
games_target = int(games_target)
|
382
|
+
if games_target < 1 or games_target > 1000:
|
383
|
+
return jsonify({'error': 'Games target must be between 1 and 1,000'}), 400
|
384
|
+
settings_to_update['games_target'] = games_target
|
385
|
+
except (ValueError, TypeError):
|
386
|
+
return jsonify({'error': 'Games target must be a valid integer'}), 400
|
387
|
+
|
287
388
|
if not settings_to_update:
|
288
389
|
return jsonify({'error': 'No valid settings provided'}), 400
|
289
390
|
|
290
391
|
# Update configuration
|
291
|
-
config =
|
392
|
+
config = get_stats_config()
|
292
393
|
|
293
394
|
if 'afk_timer_seconds' in settings_to_update:
|
294
|
-
config.
|
395
|
+
config.afk_timer_seconds = settings_to_update['afk_timer_seconds']
|
295
396
|
if 'session_gap_seconds' in settings_to_update:
|
296
|
-
config.
|
397
|
+
config.session_gap_seconds = settings_to_update['session_gap_seconds']
|
297
398
|
if 'streak_requirement_hours' in settings_to_update:
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
399
|
+
config.streak_requirement_hours = settings_to_update['streak_requirement_hours']
|
400
|
+
if 'reading_hours_target' in settings_to_update:
|
401
|
+
config.reading_hours_target = settings_to_update['reading_hours_target']
|
402
|
+
if 'character_count_target' in settings_to_update:
|
403
|
+
config.character_count_target = settings_to_update['character_count_target']
|
404
|
+
if 'games_target' in settings_to_update:
|
405
|
+
config.games_target = settings_to_update['games_target']
|
406
|
+
|
407
|
+
save_stats_config(config)
|
408
|
+
|
303
409
|
logger.info(f"Settings updated: {settings_to_update}")
|
304
410
|
|
305
411
|
response_data = {'message': 'Settings saved successfully'}
|
@@ -685,6 +791,7 @@ def register_database_api_routes(app):
|
|
685
791
|
Provides aggregated, cumulative stats for charting.
|
686
792
|
Accepts optional 'year' parameter to filter heatmap data.
|
687
793
|
"""
|
794
|
+
punctionation_regex = regex.compile(r'[\p{P}\p{S}\p{Z}]')
|
688
795
|
# Get optional year filter parameter
|
689
796
|
filter_year = request.args.get('year', None)
|
690
797
|
|
@@ -697,13 +804,33 @@ def register_database_api_routes(app):
|
|
697
804
|
# 2. Process data into daily totals for each game
|
698
805
|
# Structure: daily_data[date_str][game_name] = {'lines': N, 'chars': N}
|
699
806
|
daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
|
807
|
+
|
808
|
+
|
809
|
+
# start_time = time.perf_counter()
|
810
|
+
# for line in all_lines:
|
811
|
+
# day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
812
|
+
# game = line.game_name or "Unknown Game"
|
813
|
+
# daily_data[day_str][game]['lines'] += 1
|
814
|
+
# daily_data[day_str][game]['chars'] += len(line.line_text) if line.line_text else 0
|
815
|
+
# end_time = time.perf_counter()
|
816
|
+
# logger.info(f"Without Punctuation removal and daily aggregation took {end_time - start_time:.4f} seconds for {len(all_lines)} lines")
|
700
817
|
|
818
|
+
# start_time = time.perf_counter()
|
819
|
+
wrong_instance_found = False
|
701
820
|
for line in all_lines:
|
702
821
|
day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
703
822
|
game = line.game_name or "Unknown Game"
|
704
|
-
|
823
|
+
# Remove punctuation and symbols from line text before counting characters
|
824
|
+
clean_text = punctionation_regex.sub('', str(line.line_text)) if line.line_text else ''
|
825
|
+
if not isinstance(clean_text, str) and not wrong_instance_found:
|
826
|
+
logger.info(f"Non-string line_text encountered: {clean_text} (type: {type(clean_text)})")
|
827
|
+
wrong_instance_found = True
|
828
|
+
|
829
|
+
line.line_text = clean_text # Update line text to cleaned version for future use
|
705
830
|
daily_data[day_str][game]['lines'] += 1
|
706
|
-
daily_data[day_str][game]['chars'] += len(
|
831
|
+
daily_data[day_str][game]['chars'] += len(clean_text)
|
832
|
+
# end_time = time.perf_counter()
|
833
|
+
# logger.info(f"With Punctuation removal and daily aggregation took {end_time - start_time:.4f} seconds for {len(all_lines)} lines")
|
707
834
|
|
708
835
|
# 3. Create cumulative datasets for Chart.js
|
709
836
|
sorted_days = sorted(daily_data.keys())
|