GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.0__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.
Potentially problematic release.
This version of GameSentenceMiner might be problematic. Click here for more details.
- GameSentenceMiner/__init__.py +39 -0
- GameSentenceMiner/anki.py +6 -3
- GameSentenceMiner/gametext.py +13 -2
- GameSentenceMiner/gsm.py +40 -3
- GameSentenceMiner/locales/en_us.json +4 -0
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/obs.py +4 -1
- GameSentenceMiner/owocr/owocr/ocr.py +304 -134
- GameSentenceMiner/owocr/owocr/run.py +1 -1
- GameSentenceMiner/ui/anki_confirmation.py +4 -2
- GameSentenceMiner/ui/config_gui.py +12 -0
- GameSentenceMiner/util/configuration.py +6 -2
- GameSentenceMiner/util/cron/__init__.py +12 -0
- GameSentenceMiner/util/cron/daily_rollup.py +613 -0
- GameSentenceMiner/util/cron/jiten_update.py +397 -0
- GameSentenceMiner/util/cron/populate_games.py +154 -0
- GameSentenceMiner/util/cron/run_crons.py +148 -0
- GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
- GameSentenceMiner/util/cron_table.py +334 -0
- GameSentenceMiner/util/db.py +236 -49
- GameSentenceMiner/util/ffmpeg.py +23 -4
- GameSentenceMiner/util/games_table.py +340 -93
- GameSentenceMiner/util/jiten_api_client.py +188 -0
- GameSentenceMiner/util/stats_rollup_table.py +216 -0
- GameSentenceMiner/web/anki_api_endpoints.py +438 -220
- GameSentenceMiner/web/database_api.py +955 -1259
- GameSentenceMiner/web/jiten_database_api.py +1015 -0
- GameSentenceMiner/web/rollup_stats.py +672 -0
- GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
- GameSentenceMiner/web/static/css/overview.css +604 -47
- GameSentenceMiner/web/static/css/search.css +226 -0
- GameSentenceMiner/web/static/css/shared.css +762 -0
- GameSentenceMiner/web/static/css/stats.css +221 -0
- GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
- GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
- GameSentenceMiner/web/static/js/database-game-data.js +390 -0
- GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
- GameSentenceMiner/web/static/js/database-helpers.js +44 -0
- GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
- GameSentenceMiner/web/static/js/database-popups.js +89 -0
- GameSentenceMiner/web/static/js/database-tabs.js +64 -0
- GameSentenceMiner/web/static/js/database-text-management.js +371 -0
- GameSentenceMiner/web/static/js/database.js +86 -718
- GameSentenceMiner/web/static/js/goals.js +79 -18
- GameSentenceMiner/web/static/js/heatmap.js +29 -23
- GameSentenceMiner/web/static/js/overview.js +1205 -339
- GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
- GameSentenceMiner/web/static/js/search.js +215 -18
- GameSentenceMiner/web/static/js/shared.js +193 -39
- GameSentenceMiner/web/static/js/stats.js +1536 -179
- GameSentenceMiner/web/stats.py +1142 -269
- GameSentenceMiner/web/stats_api.py +2104 -0
- GameSentenceMiner/web/templates/anki_stats.html +4 -18
- GameSentenceMiner/web/templates/components/date-range.html +118 -3
- GameSentenceMiner/web/templates/components/html-head.html +40 -6
- GameSentenceMiner/web/templates/components/js-config.html +8 -8
- GameSentenceMiner/web/templates/components/regex-input.html +160 -0
- GameSentenceMiner/web/templates/database.html +564 -117
- GameSentenceMiner/web/templates/goals.html +41 -5
- GameSentenceMiner/web/templates/overview.html +159 -129
- GameSentenceMiner/web/templates/search.html +78 -9
- GameSentenceMiner/web/templates/stats.html +159 -5
- GameSentenceMiner/web/texthooking_page.py +280 -111
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
|
@@ -14,59 +14,129 @@ from flask import request, jsonify
|
|
|
14
14
|
import regex
|
|
15
15
|
|
|
16
16
|
from GameSentenceMiner.util.db import GameLinesTable
|
|
17
|
-
from GameSentenceMiner.util.
|
|
17
|
+
from GameSentenceMiner.util.stats_rollup_table import StatsRollupTable
|
|
18
|
+
from GameSentenceMiner.util.configuration import (
|
|
19
|
+
get_stats_config,
|
|
20
|
+
logger,
|
|
21
|
+
get_config,
|
|
22
|
+
save_current_config,
|
|
23
|
+
save_stats_config,
|
|
24
|
+
)
|
|
25
|
+
from GameSentenceMiner.util.cron.daily_rollup import run_daily_rollup
|
|
18
26
|
from GameSentenceMiner.util.text_log import GameLine
|
|
19
27
|
from GameSentenceMiner.web.stats import (
|
|
20
|
-
calculate_kanji_frequency,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
calculate_kanji_frequency,
|
|
29
|
+
calculate_mining_heatmap_data,
|
|
30
|
+
calculate_total_chars_per_game,
|
|
31
|
+
calculate_reading_time_per_game,
|
|
32
|
+
calculate_reading_speed_per_game,
|
|
33
|
+
calculate_current_game_stats,
|
|
34
|
+
calculate_all_games_stats,
|
|
35
|
+
calculate_daily_reading_time,
|
|
36
|
+
calculate_time_based_streak,
|
|
37
|
+
calculate_actual_reading_time,
|
|
38
|
+
calculate_hourly_activity,
|
|
39
|
+
calculate_hourly_reading_speed,
|
|
40
|
+
calculate_peak_daily_stats,
|
|
41
|
+
calculate_peak_session_stats,
|
|
42
|
+
calculate_game_milestones,
|
|
43
|
+
build_game_display_name_mapping,
|
|
44
|
+
)
|
|
45
|
+
from GameSentenceMiner.web.rollup_stats import (
|
|
46
|
+
aggregate_rollup_data,
|
|
47
|
+
calculate_live_stats_for_today,
|
|
48
|
+
combine_rollup_and_live_stats,
|
|
49
|
+
build_heatmap_from_rollup,
|
|
50
|
+
build_daily_chart_data_from_rollup,
|
|
25
51
|
)
|
|
26
52
|
|
|
53
|
+
|
|
27
54
|
def register_database_api_routes(app):
|
|
28
55
|
"""Register all database API routes with the Flask app."""
|
|
29
|
-
|
|
30
|
-
@app.route(
|
|
56
|
+
|
|
57
|
+
@app.route("/api/search-sentences")
|
|
31
58
|
def api_search_sentences():
|
|
32
59
|
"""
|
|
33
60
|
API endpoint for searching sentences with filters and pagination.
|
|
34
61
|
"""
|
|
35
62
|
try:
|
|
36
63
|
# Get query parameters
|
|
37
|
-
query = request.args.get(
|
|
38
|
-
game_filter = request.args.get(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
query = request.args.get("q", "").strip()
|
|
65
|
+
game_filter = request.args.get("game", "")
|
|
66
|
+
from_date = request.args.get("from_date", "").strip()
|
|
67
|
+
to_date = request.args.get("to_date", "").strip()
|
|
68
|
+
sort_by = request.args.get("sort", "relevance")
|
|
69
|
+
page = int(request.args.get("page", 1))
|
|
70
|
+
page_size = int(request.args.get("page_size", 20))
|
|
71
|
+
use_regex = request.args.get("use_regex", "false").lower() == "true"
|
|
72
|
+
|
|
44
73
|
# Validate parameters
|
|
45
74
|
if not query:
|
|
46
|
-
return jsonify({
|
|
47
|
-
|
|
75
|
+
return jsonify({"error": "Search query is required"}), 400
|
|
76
|
+
|
|
48
77
|
if page < 1:
|
|
49
78
|
page = 1
|
|
50
79
|
if page_size < 1 or page_size > 200:
|
|
51
80
|
page_size = 20
|
|
81
|
+
|
|
82
|
+
# Parse and validate date range if provided
|
|
83
|
+
date_start_timestamp = None
|
|
84
|
+
date_end_timestamp = None
|
|
85
|
+
|
|
86
|
+
if from_date:
|
|
87
|
+
try:
|
|
88
|
+
# Parse from_date in YYYY-MM-DD format
|
|
89
|
+
from_date_obj = datetime.datetime.strptime(from_date, "%Y-%m-%d")
|
|
90
|
+
# Get start of day (00:00:00)
|
|
91
|
+
date_start_timestamp = from_date_obj.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return jsonify({"error": "Invalid from_date format. Use YYYY-MM-DD"}), 400
|
|
94
|
+
|
|
95
|
+
if to_date:
|
|
96
|
+
try:
|
|
97
|
+
# Parse to_date in YYYY-MM-DD format
|
|
98
|
+
to_date_obj = datetime.datetime.strptime(to_date, "%Y-%m-%d")
|
|
99
|
+
# Get end of day (23:59:59)
|
|
100
|
+
date_end_timestamp = to_date_obj.replace(hour=23, minute=59, second=59, microsecond=999999).timestamp()
|
|
101
|
+
except ValueError:
|
|
102
|
+
return jsonify({"error": "Invalid to_date format. Use YYYY-MM-DD"}), 400
|
|
52
103
|
|
|
53
104
|
if use_regex:
|
|
54
105
|
# Regex search: fetch all candidate rows, filter in Python
|
|
55
106
|
try:
|
|
56
107
|
# Ensure query is a string
|
|
57
108
|
if not isinstance(query, str):
|
|
58
|
-
return jsonify({
|
|
59
|
-
|
|
109
|
+
return jsonify({"error": "Invalid query parameter type"}), 400
|
|
110
|
+
|
|
60
111
|
all_lines = GameLinesTable.all()
|
|
61
112
|
if game_filter:
|
|
62
|
-
all_lines = [
|
|
113
|
+
all_lines = [
|
|
114
|
+
line for line in all_lines if line.game_name == game_filter
|
|
115
|
+
]
|
|
63
116
|
|
|
117
|
+
# Apply date range filter if provided
|
|
118
|
+
if date_start_timestamp is not None or date_end_timestamp is not None:
|
|
119
|
+
filtered_lines = []
|
|
120
|
+
for line in all_lines:
|
|
121
|
+
if not line.timestamp:
|
|
122
|
+
continue
|
|
123
|
+
timestamp = float(line.timestamp)
|
|
124
|
+
# Check if timestamp is within range
|
|
125
|
+
if date_start_timestamp is not None and timestamp < date_start_timestamp:
|
|
126
|
+
continue
|
|
127
|
+
if date_end_timestamp is not None and timestamp > date_end_timestamp:
|
|
128
|
+
continue
|
|
129
|
+
filtered_lines.append(line)
|
|
130
|
+
all_lines = filtered_lines
|
|
131
|
+
|
|
64
132
|
# Compile regex pattern with proper error handling
|
|
65
133
|
try:
|
|
66
134
|
pattern = re.compile(query, re.IGNORECASE)
|
|
67
135
|
except re.error as regex_err:
|
|
68
|
-
return jsonify(
|
|
69
|
-
|
|
136
|
+
return jsonify(
|
|
137
|
+
{"error": f"Invalid regex pattern: {str(regex_err)}"}
|
|
138
|
+
), 400
|
|
139
|
+
|
|
70
140
|
# Filter lines using regex
|
|
71
141
|
filtered_lines = []
|
|
72
142
|
for line in all_lines:
|
|
@@ -76,17 +146,36 @@ def register_database_api_routes(app):
|
|
|
76
146
|
filtered_lines.append(line)
|
|
77
147
|
except Exception as search_err:
|
|
78
148
|
# Log but continue with other lines
|
|
79
|
-
logger.warning(
|
|
149
|
+
logger.warning(
|
|
150
|
+
f"Regex search error on line {line.id}: {search_err}"
|
|
151
|
+
)
|
|
80
152
|
continue
|
|
81
|
-
|
|
153
|
+
|
|
82
154
|
# Sorting (default: timestamp DESC, or as specified)
|
|
83
|
-
if sort_by ==
|
|
84
|
-
filtered_lines.sort(
|
|
85
|
-
|
|
86
|
-
|
|
155
|
+
if sort_by == "date_asc":
|
|
156
|
+
filtered_lines.sort(
|
|
157
|
+
key=lambda l: float(l.timestamp) if l.timestamp else 0
|
|
158
|
+
)
|
|
159
|
+
elif sort_by == "game_name":
|
|
160
|
+
filtered_lines.sort(
|
|
161
|
+
key=lambda l: (
|
|
162
|
+
l.game_name or "",
|
|
163
|
+
-(float(l.timestamp) if l.timestamp else 0),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
elif sort_by == "length_desc":
|
|
167
|
+
filtered_lines.sort(
|
|
168
|
+
key=lambda l: -(len(l.line_text) if l.line_text else 0)
|
|
169
|
+
)
|
|
170
|
+
elif sort_by == "length_asc":
|
|
171
|
+
filtered_lines.sort(
|
|
172
|
+
key=lambda l: len(l.line_text) if l.line_text else 0
|
|
173
|
+
)
|
|
87
174
|
else: # date_desc or relevance
|
|
88
|
-
filtered_lines.sort(
|
|
89
|
-
|
|
175
|
+
filtered_lines.sort(
|
|
176
|
+
key=lambda l: -(float(l.timestamp) if l.timestamp else 0)
|
|
177
|
+
)
|
|
178
|
+
|
|
90
179
|
total_results = len(filtered_lines)
|
|
91
180
|
# Pagination
|
|
92
181
|
start = (page - 1) * page_size
|
|
@@ -94,92 +183,128 @@ def register_database_api_routes(app):
|
|
|
94
183
|
paged_lines = filtered_lines[start:end]
|
|
95
184
|
results = []
|
|
96
185
|
for line in paged_lines:
|
|
97
|
-
results.append(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
186
|
+
results.append(
|
|
187
|
+
{
|
|
188
|
+
"id": line.id,
|
|
189
|
+
"sentence": line.line_text or "",
|
|
190
|
+
"game_name": line.game_name or "Unknown Game",
|
|
191
|
+
"timestamp": float(line.timestamp)
|
|
192
|
+
if line.timestamp
|
|
193
|
+
else 0,
|
|
194
|
+
"translation": line.translation or None,
|
|
195
|
+
"has_audio": bool(getattr(line, "audio_path", None)),
|
|
196
|
+
"has_screenshot": bool(
|
|
197
|
+
getattr(line, "screenshot_path", None)
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
return jsonify(
|
|
202
|
+
{
|
|
203
|
+
"results": results,
|
|
204
|
+
"total": total_results,
|
|
205
|
+
"page": page,
|
|
206
|
+
"page_size": page_size,
|
|
207
|
+
"total_pages": (total_results + page_size - 1) // page_size,
|
|
208
|
+
}
|
|
209
|
+
), 200
|
|
113
210
|
except Exception as e:
|
|
114
211
|
logger.error(f"Regex search failed: {e}")
|
|
115
|
-
return jsonify({
|
|
212
|
+
return jsonify({"error": f"Search failed: {str(e)}"}), 500
|
|
116
213
|
else:
|
|
117
214
|
# Build the SQL query
|
|
118
|
-
base_query =
|
|
119
|
-
|
|
120
|
-
|
|
215
|
+
base_query = (
|
|
216
|
+
f"SELECT * FROM {GameLinesTable._table} WHERE line_text LIKE ?"
|
|
217
|
+
)
|
|
218
|
+
params = [f"%{query}%"]
|
|
219
|
+
|
|
121
220
|
# Add game filter if specified
|
|
122
221
|
if game_filter:
|
|
123
222
|
base_query += " AND game_name = ?"
|
|
124
223
|
params.append(game_filter)
|
|
125
224
|
|
|
225
|
+
# Add date range filter if specified
|
|
226
|
+
if date_start_timestamp is not None:
|
|
227
|
+
base_query += " AND timestamp >= ?"
|
|
228
|
+
params.append(date_start_timestamp)
|
|
229
|
+
if date_end_timestamp is not None:
|
|
230
|
+
base_query += " AND timestamp <= ?"
|
|
231
|
+
params.append(date_end_timestamp)
|
|
232
|
+
|
|
126
233
|
# Add sorting
|
|
127
|
-
if sort_by ==
|
|
234
|
+
if sort_by == "date_desc":
|
|
128
235
|
base_query += " ORDER BY timestamp DESC"
|
|
129
|
-
elif sort_by ==
|
|
236
|
+
elif sort_by == "date_asc":
|
|
130
237
|
base_query += " ORDER BY timestamp ASC"
|
|
131
|
-
elif sort_by ==
|
|
238
|
+
elif sort_by == "game_name":
|
|
132
239
|
base_query += " ORDER BY game_name, timestamp DESC"
|
|
240
|
+
elif sort_by == "length_desc":
|
|
241
|
+
base_query += " ORDER BY LENGTH(line_text) DESC"
|
|
242
|
+
elif sort_by == "length_asc":
|
|
243
|
+
base_query += " ORDER BY LENGTH(line_text) ASC"
|
|
133
244
|
else: # relevance - could be enhanced with proper scoring
|
|
134
245
|
base_query += " ORDER BY timestamp DESC"
|
|
135
|
-
|
|
246
|
+
|
|
136
247
|
# Get total count for pagination
|
|
137
248
|
count_query = f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE line_text LIKE ?"
|
|
138
|
-
count_params = [f
|
|
249
|
+
count_params = [f"%{query}%"]
|
|
139
250
|
if game_filter:
|
|
140
251
|
count_query += " AND game_name = ?"
|
|
141
252
|
count_params.append(game_filter)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
253
|
+
if date_start_timestamp is not None:
|
|
254
|
+
count_query += " AND timestamp >= ?"
|
|
255
|
+
count_params.append(date_start_timestamp)
|
|
256
|
+
if date_end_timestamp is not None:
|
|
257
|
+
count_query += " AND timestamp <= ?"
|
|
258
|
+
count_params.append(date_end_timestamp)
|
|
259
|
+
|
|
260
|
+
total_results = GameLinesTable._db.fetchone(count_query, count_params)[
|
|
261
|
+
0
|
|
262
|
+
]
|
|
263
|
+
|
|
145
264
|
# Add pagination
|
|
146
265
|
offset = (page - 1) * page_size
|
|
147
266
|
base_query += f" LIMIT ? OFFSET ?"
|
|
148
267
|
params.extend([page_size, offset])
|
|
149
|
-
|
|
268
|
+
|
|
150
269
|
# Execute search query
|
|
151
270
|
rows = GameLinesTable._db.fetchall(base_query, params)
|
|
152
|
-
|
|
271
|
+
|
|
153
272
|
# Format results
|
|
154
273
|
results = []
|
|
155
274
|
for row in rows:
|
|
156
275
|
game_line = GameLinesTable.from_row(row)
|
|
157
276
|
if game_line:
|
|
158
|
-
results.append(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
277
|
+
results.append(
|
|
278
|
+
{
|
|
279
|
+
"id": game_line.id,
|
|
280
|
+
"sentence": game_line.line_text or "",
|
|
281
|
+
"game_name": game_line.game_name or "Unknown Game",
|
|
282
|
+
"timestamp": float(game_line.timestamp)
|
|
283
|
+
if game_line.timestamp
|
|
284
|
+
else 0,
|
|
285
|
+
"translation": game_line.translation or None,
|
|
286
|
+
"has_audio": bool(game_line.audio_path),
|
|
287
|
+
"has_screenshot": bool(game_line.screenshot_path),
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return jsonify(
|
|
292
|
+
{
|
|
293
|
+
"results": results,
|
|
294
|
+
"total": total_results,
|
|
295
|
+
"page": page,
|
|
296
|
+
"page_size": page_size,
|
|
297
|
+
"total_pages": (total_results + page_size - 1) // page_size,
|
|
298
|
+
}
|
|
299
|
+
), 200
|
|
300
|
+
|
|
176
301
|
except ValueError as e:
|
|
177
|
-
return jsonify({
|
|
302
|
+
return jsonify({"error": "Invalid pagination parameters"}), 400
|
|
178
303
|
except Exception as e:
|
|
179
304
|
logger.error(f"Error in sentence search: {e}")
|
|
180
|
-
return jsonify({
|
|
305
|
+
return jsonify({"error": "Search failed"}), 500
|
|
181
306
|
|
|
182
|
-
@app.route(
|
|
307
|
+
@app.route("/api/games-list")
|
|
183
308
|
def api_games_list():
|
|
184
309
|
"""
|
|
185
310
|
Provides game list with metadata for deletion interface.
|
|
@@ -187,335 +312,456 @@ def register_database_api_routes(app):
|
|
|
187
312
|
try:
|
|
188
313
|
game_names = GameLinesTable.get_all_games_with_lines()
|
|
189
314
|
games_data = []
|
|
190
|
-
|
|
315
|
+
|
|
191
316
|
for game_name in game_names:
|
|
192
317
|
lines = GameLinesTable.get_all_lines_for_scene(game_name)
|
|
193
318
|
if not lines:
|
|
194
319
|
continue
|
|
195
|
-
|
|
320
|
+
|
|
196
321
|
# Calculate metadata
|
|
197
322
|
sentence_count = len(lines)
|
|
198
323
|
timestamps = [float(line.timestamp) for line in lines]
|
|
199
324
|
min_date = datetime.date.fromtimestamp(min(timestamps))
|
|
200
325
|
max_date = datetime.date.fromtimestamp(max(timestamps))
|
|
201
|
-
total_chars = sum(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
326
|
+
total_chars = sum(
|
|
327
|
+
len(line.line_text) if line.line_text else 0 for line in lines
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
games_data.append(
|
|
331
|
+
{
|
|
332
|
+
"name": game_name,
|
|
333
|
+
"sentence_count": sentence_count,
|
|
334
|
+
"first_entry_date": min_date.strftime("%Y-%m-%d"),
|
|
335
|
+
"last_entry_date": max_date.strftime("%Y-%m-%d"),
|
|
336
|
+
"total_characters": total_chars,
|
|
337
|
+
"date_range": f"{min_date.strftime('%Y-%m-%d')} to {max_date.strftime('%Y-%m-%d')}"
|
|
338
|
+
if min_date != max_date
|
|
339
|
+
else min_date.strftime("%Y-%m-%d"),
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
|
|
212
343
|
# Sort by total characters (most characters first)
|
|
213
|
-
games_data.sort(key=lambda x: x[
|
|
214
|
-
|
|
215
|
-
return jsonify({
|
|
216
|
-
|
|
344
|
+
games_data.sort(key=lambda x: x["total_characters"], reverse=True)
|
|
345
|
+
|
|
346
|
+
return jsonify({"games": games_data}), 200
|
|
347
|
+
|
|
217
348
|
except Exception as e:
|
|
218
349
|
logger.error(f"Error fetching games list: {e}", exc_info=True)
|
|
219
|
-
return jsonify({
|
|
350
|
+
return jsonify({"error": "Failed to fetch games list"}), 500
|
|
220
351
|
|
|
221
|
-
@app.route(
|
|
352
|
+
@app.route("/api/delete-sentence-lines", methods=["POST"])
|
|
222
353
|
def api_delete_sentence_lines():
|
|
223
354
|
"""
|
|
224
355
|
Delete specific sentence lines by their IDs.
|
|
225
356
|
"""
|
|
226
357
|
try:
|
|
227
358
|
data = request.get_json()
|
|
228
|
-
line_ids = data.get(
|
|
229
|
-
|
|
359
|
+
line_ids = data.get("line_ids", [])
|
|
360
|
+
|
|
230
361
|
logger.debug(f"Request to delete line IDs: {line_ids}")
|
|
231
|
-
|
|
362
|
+
|
|
232
363
|
if not line_ids:
|
|
233
|
-
return jsonify({
|
|
234
|
-
|
|
364
|
+
return jsonify({"error": "No line IDs provided"}), 400
|
|
365
|
+
|
|
235
366
|
if not isinstance(line_ids, list):
|
|
236
|
-
return jsonify({
|
|
237
|
-
|
|
367
|
+
return jsonify({"error": "line_ids must be a list"}), 400
|
|
368
|
+
|
|
238
369
|
# Delete the lines
|
|
239
370
|
deleted_count = 0
|
|
240
371
|
failed_ids = []
|
|
241
|
-
|
|
372
|
+
|
|
242
373
|
for line_id in line_ids:
|
|
243
374
|
try:
|
|
244
375
|
GameLinesTable._db.execute(
|
|
245
376
|
f"DELETE FROM {GameLinesTable._table} WHERE id=?",
|
|
246
377
|
(line_id,),
|
|
247
|
-
commit=True
|
|
378
|
+
commit=True,
|
|
248
379
|
)
|
|
249
380
|
deleted_count += 1
|
|
250
381
|
except Exception as e:
|
|
251
382
|
logger.warning(f"Failed to delete line {line_id}: {e}")
|
|
252
383
|
failed_ids.append(line_id)
|
|
253
|
-
|
|
254
|
-
logger.info(
|
|
255
|
-
|
|
384
|
+
|
|
385
|
+
logger.info(
|
|
386
|
+
f"Deleted {deleted_count} sentence lines out of {len(line_ids)} requested"
|
|
387
|
+
)
|
|
388
|
+
|
|
256
389
|
response_data = {
|
|
257
|
-
|
|
258
|
-
|
|
390
|
+
"deleted_count": deleted_count,
|
|
391
|
+
"message": f"Successfully deleted {deleted_count} {'sentence' if deleted_count == 1 else 'sentences'}",
|
|
259
392
|
}
|
|
260
|
-
|
|
393
|
+
|
|
261
394
|
if failed_ids:
|
|
262
|
-
response_data[
|
|
263
|
-
response_data[
|
|
264
|
-
|
|
395
|
+
response_data["warning"] = f"{len(failed_ids)} lines failed to delete"
|
|
396
|
+
response_data["failed_ids"] = failed_ids
|
|
397
|
+
|
|
398
|
+
# Trigger stats rollup after successful deletion
|
|
399
|
+
if deleted_count > 0:
|
|
400
|
+
try:
|
|
401
|
+
logger.info("Triggering stats rollup after sentence line deletion")
|
|
402
|
+
run_daily_rollup()
|
|
403
|
+
except Exception as rollup_error:
|
|
404
|
+
logger.error(f"Stats rollup failed after sentence line deletion: {rollup_error}")
|
|
405
|
+
# Don't fail the deletion operation if rollup fails
|
|
406
|
+
|
|
265
407
|
return jsonify(response_data), 200
|
|
266
|
-
|
|
408
|
+
|
|
267
409
|
except Exception as e:
|
|
268
410
|
logger.error(f"Error in sentence line deletion: {e}")
|
|
269
|
-
return jsonify({
|
|
411
|
+
return jsonify({"error": f"Failed to delete sentences: {str(e)}"}), 500
|
|
270
412
|
|
|
271
|
-
@app.route(
|
|
413
|
+
@app.route("/api/delete-games", methods=["POST"])
|
|
272
414
|
def api_delete_games():
|
|
273
415
|
"""
|
|
274
416
|
Handles bulk deletion of games and their associated data.
|
|
275
417
|
"""
|
|
276
418
|
try:
|
|
277
419
|
data = request.get_json()
|
|
278
|
-
game_names = data.get(
|
|
279
|
-
|
|
420
|
+
game_names = data.get("game_names", [])
|
|
421
|
+
|
|
280
422
|
if not game_names:
|
|
281
|
-
return jsonify({
|
|
282
|
-
|
|
423
|
+
return jsonify({"error": "No games specified for deletion"}), 400
|
|
424
|
+
|
|
283
425
|
if not isinstance(game_names, list):
|
|
284
|
-
return jsonify({
|
|
285
|
-
|
|
426
|
+
return jsonify({"error": "game_names must be a list"}), 400
|
|
427
|
+
|
|
286
428
|
# Validate that all games exist
|
|
287
429
|
existing_games = GameLinesTable.get_all_games_with_lines()
|
|
288
430
|
invalid_games = [name for name in game_names if name not in existing_games]
|
|
289
|
-
|
|
431
|
+
|
|
290
432
|
if invalid_games:
|
|
291
|
-
return jsonify(
|
|
292
|
-
|
|
433
|
+
return jsonify(
|
|
434
|
+
{"error": f"Games not found: {', '.join(invalid_games)}"}
|
|
435
|
+
), 400
|
|
436
|
+
|
|
293
437
|
deletion_results = {}
|
|
294
438
|
total_deleted = 0
|
|
295
|
-
|
|
439
|
+
|
|
296
440
|
# Delete each game's data
|
|
297
441
|
for game_name in game_names:
|
|
298
442
|
try:
|
|
299
443
|
# Get lines for this game before deletion for counting
|
|
300
444
|
lines = GameLinesTable.get_all_lines_for_scene(game_name)
|
|
301
445
|
lines_count = len(lines)
|
|
302
|
-
|
|
446
|
+
|
|
303
447
|
# Delete all lines for this game using the database connection
|
|
304
448
|
GameLinesTable._db.execute(
|
|
305
449
|
f"DELETE FROM {GameLinesTable._table} WHERE game_name=?",
|
|
306
450
|
(game_name,),
|
|
307
|
-
commit=True
|
|
451
|
+
commit=True,
|
|
308
452
|
)
|
|
309
|
-
|
|
453
|
+
|
|
310
454
|
deletion_results[game_name] = {
|
|
311
|
-
|
|
312
|
-
|
|
455
|
+
"deleted_sentences": lines_count,
|
|
456
|
+
"status": "success",
|
|
313
457
|
}
|
|
314
458
|
total_deleted += lines_count
|
|
315
|
-
|
|
316
|
-
logger.info(
|
|
317
|
-
|
|
459
|
+
|
|
460
|
+
logger.info(
|
|
461
|
+
f"Deleted {lines_count} sentences for game: {game_name}"
|
|
462
|
+
)
|
|
463
|
+
|
|
318
464
|
except Exception as e:
|
|
319
465
|
logger.error(f"Error deleting game {game_name}: {e}")
|
|
320
466
|
deletion_results[game_name] = {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
467
|
+
"deleted_sentences": 0,
|
|
468
|
+
"status": "error",
|
|
469
|
+
"error": str(e),
|
|
324
470
|
}
|
|
325
|
-
|
|
471
|
+
|
|
326
472
|
# Check if any deletions were successful
|
|
327
|
-
successful_deletions = [
|
|
328
|
-
|
|
329
|
-
|
|
473
|
+
successful_deletions = [
|
|
474
|
+
name
|
|
475
|
+
for name, result in deletion_results.items()
|
|
476
|
+
if result["status"] == "success"
|
|
477
|
+
]
|
|
478
|
+
failed_deletions = [
|
|
479
|
+
name
|
|
480
|
+
for name, result in deletion_results.items()
|
|
481
|
+
if result["status"] == "error"
|
|
482
|
+
]
|
|
483
|
+
|
|
330
484
|
response_data = {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
485
|
+
"message": f"Deletion completed. {len(successful_deletions)} games successfully deleted.",
|
|
486
|
+
"total_sentences_deleted": total_deleted,
|
|
487
|
+
"successful_games": successful_deletions,
|
|
488
|
+
"failed_games": failed_deletions,
|
|
489
|
+
"detailed_results": deletion_results,
|
|
336
490
|
}
|
|
337
|
-
|
|
491
|
+
|
|
338
492
|
if failed_deletions:
|
|
339
|
-
response_data[
|
|
340
|
-
|
|
493
|
+
response_data["warning"] = (
|
|
494
|
+
f"Some games failed to delete: {', '.join(failed_deletions)}"
|
|
495
|
+
)
|
|
496
|
+
status_code = 207 # Multi-Status (partial success)
|
|
341
497
|
else:
|
|
342
|
-
|
|
343
|
-
|
|
498
|
+
status_code = 200
|
|
499
|
+
|
|
500
|
+
# Trigger stats rollup after successful deletion
|
|
501
|
+
if successful_deletions:
|
|
502
|
+
try:
|
|
503
|
+
logger.info("Triggering stats rollup after game deletion")
|
|
504
|
+
run_daily_rollup()
|
|
505
|
+
except Exception as rollup_error:
|
|
506
|
+
logger.error(f"Stats rollup failed after game deletion: {rollup_error}")
|
|
507
|
+
# Don't fail the deletion operation if rollup fails
|
|
508
|
+
|
|
509
|
+
return jsonify(response_data), status_code
|
|
510
|
+
|
|
344
511
|
except Exception as e:
|
|
345
512
|
logger.error(f"Error in bulk game deletion: {e}")
|
|
346
|
-
return jsonify({
|
|
513
|
+
return jsonify({"error": f"Failed to delete games: {str(e)}"}), 500
|
|
347
514
|
|
|
348
|
-
@app.route(
|
|
515
|
+
@app.route("/api/settings", methods=["GET"])
|
|
349
516
|
def api_get_settings():
|
|
350
517
|
"""
|
|
351
518
|
Get current AFK timer, session gap, streak requirement, and goal settings.
|
|
352
519
|
"""
|
|
353
520
|
try:
|
|
354
521
|
config = get_stats_config()
|
|
355
|
-
return jsonify(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
522
|
+
return jsonify(
|
|
523
|
+
{
|
|
524
|
+
"afk_timer_seconds": config.afk_timer_seconds,
|
|
525
|
+
"session_gap_seconds": config.session_gap_seconds,
|
|
526
|
+
"streak_requirement_hours": config.streak_requirement_hours,
|
|
527
|
+
"reading_hours_target": config.reading_hours_target,
|
|
528
|
+
"character_count_target": config.character_count_target,
|
|
529
|
+
"games_target": config.games_target,
|
|
530
|
+
"reading_hours_target_date": config.reading_hours_target_date,
|
|
531
|
+
"character_count_target_date": config.character_count_target_date,
|
|
532
|
+
"games_target_date": config.games_target_date,
|
|
533
|
+
"cards_mined_daily_target": getattr(config, 'cards_mined_daily_target', 10),
|
|
534
|
+
}
|
|
535
|
+
), 200
|
|
366
536
|
except Exception as e:
|
|
367
537
|
logger.error(f"Error getting settings: {e}")
|
|
368
|
-
return jsonify({
|
|
538
|
+
return jsonify({"error": "Failed to get settings"}), 500
|
|
369
539
|
|
|
370
|
-
@app.route(
|
|
540
|
+
@app.route("/api/settings", methods=["POST"])
|
|
371
541
|
def api_save_settings():
|
|
372
542
|
"""
|
|
373
543
|
Save/update AFK timer, session gap, streak requirement, and goal settings.
|
|
374
544
|
"""
|
|
375
545
|
try:
|
|
376
546
|
data = request.get_json()
|
|
377
|
-
|
|
547
|
+
|
|
378
548
|
if not data:
|
|
379
|
-
return jsonify({
|
|
380
|
-
|
|
381
|
-
afk_timer = data.get(
|
|
382
|
-
session_gap = data.get(
|
|
383
|
-
streak_requirement = data.get(
|
|
384
|
-
reading_hours_target = data.get(
|
|
385
|
-
character_count_target = data.get(
|
|
386
|
-
games_target = data.get(
|
|
387
|
-
reading_hours_target_date = data.get(
|
|
388
|
-
character_count_target_date = data.get(
|
|
389
|
-
games_target_date = data.get(
|
|
390
|
-
|
|
549
|
+
return jsonify({"error": "No data provided"}), 400
|
|
550
|
+
|
|
551
|
+
afk_timer = data.get("afk_timer_seconds")
|
|
552
|
+
session_gap = data.get("session_gap_seconds")
|
|
553
|
+
streak_requirement = data.get("streak_requirement_hours")
|
|
554
|
+
reading_hours_target = data.get("reading_hours_target")
|
|
555
|
+
character_count_target = data.get("character_count_target")
|
|
556
|
+
games_target = data.get("games_target")
|
|
557
|
+
reading_hours_target_date = data.get("reading_hours_target_date")
|
|
558
|
+
character_count_target_date = data.get("character_count_target_date")
|
|
559
|
+
games_target_date = data.get("games_target_date")
|
|
560
|
+
cards_mined_daily_target = data.get("cards_mined_daily_target")
|
|
561
|
+
|
|
391
562
|
# Validate input - only require the settings that are provided
|
|
392
563
|
settings_to_update = {}
|
|
393
|
-
|
|
564
|
+
|
|
394
565
|
if afk_timer is not None:
|
|
395
566
|
try:
|
|
396
567
|
afk_timer = int(afk_timer)
|
|
397
568
|
if afk_timer < 0 or afk_timer > 600:
|
|
398
|
-
return jsonify(
|
|
399
|
-
|
|
569
|
+
return jsonify(
|
|
570
|
+
{"error": "AFK timer must be between 0 and 600 seconds"}
|
|
571
|
+
), 400
|
|
572
|
+
settings_to_update["afk_timer_seconds"] = afk_timer
|
|
400
573
|
except (ValueError, TypeError):
|
|
401
|
-
return jsonify({
|
|
402
|
-
|
|
574
|
+
return jsonify({"error": "AFK timer must be a valid integer"}), 400
|
|
575
|
+
|
|
403
576
|
if session_gap is not None:
|
|
404
577
|
try:
|
|
405
578
|
session_gap = int(session_gap)
|
|
406
579
|
if session_gap < 0 or session_gap > 7200:
|
|
407
|
-
return jsonify(
|
|
408
|
-
|
|
580
|
+
return jsonify(
|
|
581
|
+
{
|
|
582
|
+
"error": "Session gap must be between 0 and 7200 seconds (0 to 2 hours)"
|
|
583
|
+
}
|
|
584
|
+
), 400
|
|
585
|
+
settings_to_update["session_gap_seconds"] = session_gap
|
|
409
586
|
except (ValueError, TypeError):
|
|
410
|
-
return jsonify(
|
|
411
|
-
|
|
587
|
+
return jsonify(
|
|
588
|
+
{"error": "Session gap must be a valid integer"}
|
|
589
|
+
), 400
|
|
590
|
+
|
|
412
591
|
if streak_requirement is not None:
|
|
413
592
|
try:
|
|
414
593
|
streak_requirement = float(streak_requirement)
|
|
415
594
|
if streak_requirement < 0.01 or streak_requirement > 24:
|
|
416
|
-
return jsonify(
|
|
417
|
-
|
|
595
|
+
return jsonify(
|
|
596
|
+
{
|
|
597
|
+
"error": "Streak requirement must be between 0.01 and 24 hours"
|
|
598
|
+
}
|
|
599
|
+
), 400
|
|
600
|
+
settings_to_update["streak_requirement_hours"] = streak_requirement
|
|
418
601
|
except (ValueError, TypeError):
|
|
419
|
-
return jsonify(
|
|
420
|
-
|
|
602
|
+
return jsonify(
|
|
603
|
+
{"error": "Streak requirement must be a valid number"}
|
|
604
|
+
), 400
|
|
605
|
+
|
|
421
606
|
if reading_hours_target is not None:
|
|
422
607
|
try:
|
|
423
608
|
reading_hours_target = int(reading_hours_target)
|
|
424
609
|
if reading_hours_target < 1 or reading_hours_target > 10000:
|
|
425
|
-
return jsonify(
|
|
426
|
-
|
|
610
|
+
return jsonify(
|
|
611
|
+
{
|
|
612
|
+
"error": "Reading hours target must be between 1 and 10,000 hours"
|
|
613
|
+
}
|
|
614
|
+
), 400
|
|
615
|
+
settings_to_update["reading_hours_target"] = reading_hours_target
|
|
427
616
|
except (ValueError, TypeError):
|
|
428
|
-
return jsonify(
|
|
429
|
-
|
|
617
|
+
return jsonify(
|
|
618
|
+
{"error": "Reading hours target must be a valid integer"}
|
|
619
|
+
), 400
|
|
620
|
+
|
|
430
621
|
if character_count_target is not None:
|
|
431
622
|
try:
|
|
432
623
|
character_count_target = int(character_count_target)
|
|
433
|
-
if
|
|
434
|
-
|
|
435
|
-
|
|
624
|
+
if (
|
|
625
|
+
character_count_target < 1000
|
|
626
|
+
or character_count_target > 1000000000
|
|
627
|
+
):
|
|
628
|
+
return jsonify(
|
|
629
|
+
{
|
|
630
|
+
"error": "Character count target must be between 1,000 and 1,000,000,000 characters"
|
|
631
|
+
}
|
|
632
|
+
), 400
|
|
633
|
+
settings_to_update["character_count_target"] = (
|
|
634
|
+
character_count_target
|
|
635
|
+
)
|
|
436
636
|
except (ValueError, TypeError):
|
|
437
|
-
return jsonify(
|
|
438
|
-
|
|
637
|
+
return jsonify(
|
|
638
|
+
{"error": "Character count target must be a valid integer"}
|
|
639
|
+
), 400
|
|
640
|
+
|
|
439
641
|
if games_target is not None:
|
|
440
642
|
try:
|
|
441
643
|
games_target = int(games_target)
|
|
442
644
|
if games_target < 1 or games_target > 1000:
|
|
443
|
-
return jsonify(
|
|
444
|
-
|
|
645
|
+
return jsonify(
|
|
646
|
+
{"error": "Games target must be between 1 and 1,000"}
|
|
647
|
+
), 400
|
|
648
|
+
settings_to_update["games_target"] = games_target
|
|
445
649
|
except (ValueError, TypeError):
|
|
446
|
-
return jsonify(
|
|
447
|
-
|
|
650
|
+
return jsonify(
|
|
651
|
+
{"error": "Games target must be a valid integer"}
|
|
652
|
+
), 400
|
|
653
|
+
|
|
448
654
|
# Validate target dates (ISO format: YYYY-MM-DD)
|
|
449
655
|
if reading_hours_target_date is not None:
|
|
450
|
-
if reading_hours_target_date ==
|
|
451
|
-
settings_to_update[
|
|
656
|
+
if reading_hours_target_date == "":
|
|
657
|
+
settings_to_update["reading_hours_target_date"] = ""
|
|
452
658
|
else:
|
|
453
659
|
try:
|
|
454
|
-
datetime.datetime.strptime(
|
|
455
|
-
|
|
660
|
+
datetime.datetime.strptime(
|
|
661
|
+
reading_hours_target_date, "%Y-%m-%d"
|
|
662
|
+
)
|
|
663
|
+
settings_to_update["reading_hours_target_date"] = (
|
|
664
|
+
reading_hours_target_date
|
|
665
|
+
)
|
|
456
666
|
except ValueError:
|
|
457
|
-
return jsonify(
|
|
458
|
-
|
|
667
|
+
return jsonify(
|
|
668
|
+
{
|
|
669
|
+
"error": "Reading hours target date must be in YYYY-MM-DD format"
|
|
670
|
+
}
|
|
671
|
+
), 400
|
|
672
|
+
|
|
459
673
|
if character_count_target_date is not None:
|
|
460
|
-
if character_count_target_date ==
|
|
461
|
-
settings_to_update[
|
|
674
|
+
if character_count_target_date == "":
|
|
675
|
+
settings_to_update["character_count_target_date"] = ""
|
|
462
676
|
else:
|
|
463
677
|
try:
|
|
464
|
-
datetime.datetime.strptime(
|
|
465
|
-
|
|
678
|
+
datetime.datetime.strptime(
|
|
679
|
+
character_count_target_date, "%Y-%m-%d"
|
|
680
|
+
)
|
|
681
|
+
settings_to_update["character_count_target_date"] = (
|
|
682
|
+
character_count_target_date
|
|
683
|
+
)
|
|
466
684
|
except ValueError:
|
|
467
|
-
return jsonify(
|
|
468
|
-
|
|
685
|
+
return jsonify(
|
|
686
|
+
{
|
|
687
|
+
"error": "Character count target date must be in YYYY-MM-DD format"
|
|
688
|
+
}
|
|
689
|
+
), 400
|
|
690
|
+
|
|
469
691
|
if games_target_date is not None:
|
|
470
|
-
if games_target_date ==
|
|
471
|
-
settings_to_update[
|
|
692
|
+
if games_target_date == "":
|
|
693
|
+
settings_to_update["games_target_date"] = ""
|
|
472
694
|
else:
|
|
473
695
|
try:
|
|
474
|
-
datetime.datetime.strptime(games_target_date,
|
|
475
|
-
settings_to_update[
|
|
696
|
+
datetime.datetime.strptime(games_target_date, "%Y-%m-%d")
|
|
697
|
+
settings_to_update["games_target_date"] = games_target_date
|
|
476
698
|
except ValueError:
|
|
477
|
-
return jsonify(
|
|
478
|
-
|
|
699
|
+
return jsonify(
|
|
700
|
+
{"error": "Games target date must be in YYYY-MM-DD format"}
|
|
701
|
+
), 400
|
|
702
|
+
|
|
703
|
+
if cards_mined_daily_target is not None:
|
|
704
|
+
try:
|
|
705
|
+
cards_mined_daily_target = int(cards_mined_daily_target)
|
|
706
|
+
if cards_mined_daily_target < 0 or cards_mined_daily_target > 1000:
|
|
707
|
+
return jsonify(
|
|
708
|
+
{"error": "Cards mined daily target must be between 0 and 1,000"}
|
|
709
|
+
), 400
|
|
710
|
+
settings_to_update["cards_mined_daily_target"] = cards_mined_daily_target
|
|
711
|
+
except (ValueError, TypeError):
|
|
712
|
+
return jsonify(
|
|
713
|
+
{"error": "Cards mined daily target must be a valid integer"}
|
|
714
|
+
), 400
|
|
715
|
+
|
|
479
716
|
if not settings_to_update:
|
|
480
|
-
return jsonify({
|
|
481
|
-
|
|
717
|
+
return jsonify({"error": "No valid settings provided"}), 400
|
|
718
|
+
|
|
482
719
|
# Update configuration
|
|
483
720
|
config = get_stats_config()
|
|
484
|
-
|
|
485
|
-
if
|
|
486
|
-
config.afk_timer_seconds = settings_to_update[
|
|
487
|
-
if
|
|
488
|
-
config.session_gap_seconds = settings_to_update[
|
|
489
|
-
if
|
|
490
|
-
config.streak_requirement_hours = settings_to_update[
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if
|
|
494
|
-
config.
|
|
495
|
-
if
|
|
496
|
-
config.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if
|
|
500
|
-
config.
|
|
501
|
-
if
|
|
502
|
-
config.
|
|
503
|
-
|
|
721
|
+
|
|
722
|
+
if "afk_timer_seconds" in settings_to_update:
|
|
723
|
+
config.afk_timer_seconds = settings_to_update["afk_timer_seconds"]
|
|
724
|
+
if "session_gap_seconds" in settings_to_update:
|
|
725
|
+
config.session_gap_seconds = settings_to_update["session_gap_seconds"]
|
|
726
|
+
if "streak_requirement_hours" in settings_to_update:
|
|
727
|
+
config.streak_requirement_hours = settings_to_update[
|
|
728
|
+
"streak_requirement_hours"
|
|
729
|
+
]
|
|
730
|
+
if "reading_hours_target" in settings_to_update:
|
|
731
|
+
config.reading_hours_target = settings_to_update["reading_hours_target"]
|
|
732
|
+
if "character_count_target" in settings_to_update:
|
|
733
|
+
config.character_count_target = settings_to_update[
|
|
734
|
+
"character_count_target"
|
|
735
|
+
]
|
|
736
|
+
if "games_target" in settings_to_update:
|
|
737
|
+
config.games_target = settings_to_update["games_target"]
|
|
738
|
+
if "reading_hours_target_date" in settings_to_update:
|
|
739
|
+
config.reading_hours_target_date = settings_to_update[
|
|
740
|
+
"reading_hours_target_date"
|
|
741
|
+
]
|
|
742
|
+
if "character_count_target_date" in settings_to_update:
|
|
743
|
+
config.character_count_target_date = settings_to_update[
|
|
744
|
+
"character_count_target_date"
|
|
745
|
+
]
|
|
746
|
+
if "games_target_date" in settings_to_update:
|
|
747
|
+
config.games_target_date = settings_to_update["games_target_date"]
|
|
748
|
+
if "cards_mined_daily_target" in settings_to_update:
|
|
749
|
+
config.cards_mined_daily_target = settings_to_update["cards_mined_daily_target"]
|
|
750
|
+
|
|
504
751
|
save_stats_config(config)
|
|
505
752
|
|
|
506
753
|
logger.info(f"Settings updated: {settings_to_update}")
|
|
507
|
-
|
|
508
|
-
response_data = {
|
|
754
|
+
|
|
755
|
+
response_data = {"message": "Settings saved successfully"}
|
|
509
756
|
response_data.update(settings_to_update)
|
|
510
|
-
|
|
757
|
+
|
|
511
758
|
return jsonify(response_data), 200
|
|
512
|
-
|
|
759
|
+
|
|
513
760
|
except Exception as e:
|
|
514
761
|
logger.error(f"Error saving settings: {e}")
|
|
515
|
-
return jsonify({
|
|
762
|
+
return jsonify({"error": "Failed to save settings"}), 500
|
|
516
763
|
|
|
517
|
-
|
|
518
|
-
@app.route('/api/preview-text-deletion', methods=['POST'])
|
|
764
|
+
@app.route("/api/preview-text-deletion", methods=["POST"])
|
|
519
765
|
def api_preview_text_deletion():
|
|
520
766
|
"""
|
|
521
767
|
Preview text lines that would be deleted based on regex or exact text matching.
|
|
@@ -523,40 +769,46 @@ def register_database_api_routes(app):
|
|
|
523
769
|
try:
|
|
524
770
|
data = request.get_json()
|
|
525
771
|
if not data:
|
|
526
|
-
return jsonify({
|
|
527
|
-
|
|
528
|
-
regex_pattern = data.get(
|
|
529
|
-
exact_text = data.get(
|
|
530
|
-
case_sensitive = data.get(
|
|
531
|
-
use_regex = data.get(
|
|
532
|
-
|
|
772
|
+
return jsonify({"error": "No data provided"}), 400
|
|
773
|
+
|
|
774
|
+
regex_pattern = data.get("regex_pattern")
|
|
775
|
+
exact_text = data.get("exact_text")
|
|
776
|
+
case_sensitive = data.get("case_sensitive", False)
|
|
777
|
+
use_regex = data.get("use_regex", False)
|
|
778
|
+
|
|
533
779
|
if not regex_pattern and not exact_text:
|
|
534
|
-
return jsonify(
|
|
535
|
-
|
|
780
|
+
return jsonify(
|
|
781
|
+
{"error": "Either regex_pattern or exact_text must be provided"}
|
|
782
|
+
), 400
|
|
783
|
+
|
|
536
784
|
# Get all lines from database
|
|
537
785
|
all_lines = GameLinesTable.all()
|
|
538
786
|
if not all_lines:
|
|
539
|
-
return jsonify({
|
|
540
|
-
|
|
787
|
+
return jsonify({"count": 0, "samples": []}), 200
|
|
788
|
+
|
|
541
789
|
matches = []
|
|
542
|
-
|
|
790
|
+
|
|
543
791
|
if regex_pattern and use_regex:
|
|
544
792
|
# Use regex matching
|
|
545
793
|
try:
|
|
546
794
|
# Ensure regex_pattern is a string
|
|
547
795
|
if not isinstance(regex_pattern, str):
|
|
548
|
-
return jsonify({
|
|
549
|
-
|
|
796
|
+
return jsonify({"error": "Regex pattern must be a string"}), 400
|
|
797
|
+
|
|
550
798
|
flags = 0 if case_sensitive else re.IGNORECASE
|
|
551
799
|
pattern = re.compile(regex_pattern, flags)
|
|
552
|
-
|
|
800
|
+
|
|
553
801
|
for line in all_lines:
|
|
554
|
-
if
|
|
802
|
+
if (
|
|
803
|
+
line.line_text
|
|
804
|
+
and isinstance(line.line_text, str)
|
|
805
|
+
and pattern.search(line.line_text)
|
|
806
|
+
):
|
|
555
807
|
matches.append(line.line_text)
|
|
556
|
-
|
|
808
|
+
|
|
557
809
|
except re.error as e:
|
|
558
|
-
return jsonify({
|
|
559
|
-
|
|
810
|
+
return jsonify({"error": f"Invalid regex pattern: {str(e)}"}), 400
|
|
811
|
+
|
|
560
812
|
elif exact_text:
|
|
561
813
|
# Use exact text matching - ensure exact_text is properly handled
|
|
562
814
|
if isinstance(exact_text, list):
|
|
@@ -564,21 +816,27 @@ def register_database_api_routes(app):
|
|
|
564
816
|
elif isinstance(exact_text, str):
|
|
565
817
|
text_lines = [exact_text]
|
|
566
818
|
else:
|
|
567
|
-
return jsonify(
|
|
568
|
-
|
|
819
|
+
return jsonify(
|
|
820
|
+
{"error": "exact_text must be a string or list of strings"}
|
|
821
|
+
), 400
|
|
822
|
+
|
|
569
823
|
for line in all_lines:
|
|
570
824
|
if line.line_text and isinstance(line.line_text, str):
|
|
571
|
-
line_text =
|
|
572
|
-
|
|
825
|
+
line_text = (
|
|
826
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
827
|
+
)
|
|
828
|
+
|
|
573
829
|
for target_text in text_lines:
|
|
574
830
|
# Ensure target_text is a string
|
|
575
831
|
if not isinstance(target_text, str):
|
|
576
832
|
continue
|
|
577
|
-
compare_text =
|
|
833
|
+
compare_text = (
|
|
834
|
+
target_text if case_sensitive else target_text.lower()
|
|
835
|
+
)
|
|
578
836
|
if compare_text in line_text:
|
|
579
837
|
matches.append(line.line_text)
|
|
580
838
|
break
|
|
581
|
-
|
|
839
|
+
|
|
582
840
|
# Remove duplicates while preserving order
|
|
583
841
|
unique_matches = []
|
|
584
842
|
seen = set()
|
|
@@ -586,20 +844,17 @@ def register_database_api_routes(app):
|
|
|
586
844
|
if match not in seen:
|
|
587
845
|
unique_matches.append(match)
|
|
588
846
|
seen.add(match)
|
|
589
|
-
|
|
847
|
+
|
|
590
848
|
# Get sample matches (first 10)
|
|
591
849
|
samples = unique_matches[:10]
|
|
592
|
-
|
|
593
|
-
return jsonify({
|
|
594
|
-
|
|
595
|
-
'samples': samples
|
|
596
|
-
}), 200
|
|
597
|
-
|
|
850
|
+
|
|
851
|
+
return jsonify({"count": len(unique_matches), "samples": samples}), 200
|
|
852
|
+
|
|
598
853
|
except Exception as e:
|
|
599
854
|
logger.error(f"Error in preview text deletion: {e}")
|
|
600
|
-
return jsonify({
|
|
855
|
+
return jsonify({"error": f"Preview failed: {str(e)}"}), 500
|
|
601
856
|
|
|
602
|
-
@app.route(
|
|
857
|
+
@app.route("/api/delete-text-lines", methods=["POST"])
|
|
603
858
|
def api_delete_text_lines():
|
|
604
859
|
"""
|
|
605
860
|
Delete text lines from database based on regex or exact text matching.
|
|
@@ -607,40 +862,46 @@ def register_database_api_routes(app):
|
|
|
607
862
|
try:
|
|
608
863
|
data = request.get_json()
|
|
609
864
|
if not data:
|
|
610
|
-
return jsonify({
|
|
611
|
-
|
|
612
|
-
regex_pattern = data.get(
|
|
613
|
-
exact_text = data.get(
|
|
614
|
-
case_sensitive = data.get(
|
|
615
|
-
use_regex = data.get(
|
|
616
|
-
|
|
865
|
+
return jsonify({"error": "No data provided"}), 400
|
|
866
|
+
|
|
867
|
+
regex_pattern = data.get("regex_pattern")
|
|
868
|
+
exact_text = data.get("exact_text")
|
|
869
|
+
case_sensitive = data.get("case_sensitive", False)
|
|
870
|
+
use_regex = data.get("use_regex", False)
|
|
871
|
+
|
|
617
872
|
if not regex_pattern and not exact_text:
|
|
618
|
-
return jsonify(
|
|
619
|
-
|
|
873
|
+
return jsonify(
|
|
874
|
+
{"error": "Either regex_pattern or exact_text must be provided"}
|
|
875
|
+
), 400
|
|
876
|
+
|
|
620
877
|
# Get all lines from database
|
|
621
878
|
all_lines = GameLinesTable.all()
|
|
622
879
|
if not all_lines:
|
|
623
|
-
return jsonify({
|
|
624
|
-
|
|
880
|
+
return jsonify({"deleted_count": 0}), 200
|
|
881
|
+
|
|
625
882
|
lines_to_delete = []
|
|
626
|
-
|
|
883
|
+
|
|
627
884
|
if regex_pattern and use_regex:
|
|
628
885
|
# Use regex matching
|
|
629
886
|
try:
|
|
630
887
|
# Ensure regex_pattern is a string
|
|
631
888
|
if not isinstance(regex_pattern, str):
|
|
632
|
-
return jsonify({
|
|
633
|
-
|
|
889
|
+
return jsonify({"error": "Regex pattern must be a string"}), 400
|
|
890
|
+
|
|
634
891
|
flags = 0 if case_sensitive else re.IGNORECASE
|
|
635
892
|
pattern = re.compile(regex_pattern, flags)
|
|
636
|
-
|
|
893
|
+
|
|
637
894
|
for line in all_lines:
|
|
638
|
-
if
|
|
895
|
+
if (
|
|
896
|
+
line.line_text
|
|
897
|
+
and isinstance(line.line_text, str)
|
|
898
|
+
and pattern.search(line.line_text)
|
|
899
|
+
):
|
|
639
900
|
lines_to_delete.append(line.id)
|
|
640
|
-
|
|
901
|
+
|
|
641
902
|
except re.error as e:
|
|
642
|
-
return jsonify({
|
|
643
|
-
|
|
903
|
+
return jsonify({"error": f"Invalid regex pattern: {str(e)}"}), 400
|
|
904
|
+
|
|
644
905
|
elif exact_text:
|
|
645
906
|
# Use exact text matching - ensure exact_text is properly handled
|
|
646
907
|
if isinstance(exact_text, list):
|
|
@@ -648,21 +909,27 @@ def register_database_api_routes(app):
|
|
|
648
909
|
elif isinstance(exact_text, str):
|
|
649
910
|
text_lines = [exact_text]
|
|
650
911
|
else:
|
|
651
|
-
return jsonify(
|
|
652
|
-
|
|
912
|
+
return jsonify(
|
|
913
|
+
{"error": "exact_text must be a string or list of strings"}
|
|
914
|
+
), 400
|
|
915
|
+
|
|
653
916
|
for line in all_lines:
|
|
654
917
|
if line.line_text and isinstance(line.line_text, str):
|
|
655
|
-
line_text =
|
|
656
|
-
|
|
918
|
+
line_text = (
|
|
919
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
920
|
+
)
|
|
921
|
+
|
|
657
922
|
for target_text in text_lines:
|
|
658
923
|
# Ensure target_text is a string
|
|
659
924
|
if not isinstance(target_text, str):
|
|
660
925
|
continue
|
|
661
|
-
compare_text =
|
|
926
|
+
compare_text = (
|
|
927
|
+
target_text if case_sensitive else target_text.lower()
|
|
928
|
+
)
|
|
662
929
|
if compare_text in line_text:
|
|
663
930
|
lines_to_delete.append(line.id)
|
|
664
931
|
break
|
|
665
|
-
|
|
932
|
+
|
|
666
933
|
# Delete the matching lines
|
|
667
934
|
deleted_count = 0
|
|
668
935
|
for line_id in set(lines_to_delete): # Remove duplicates
|
|
@@ -670,24 +937,37 @@ def register_database_api_routes(app):
|
|
|
670
937
|
GameLinesTable._db.execute(
|
|
671
938
|
f"DELETE FROM {GameLinesTable._table} WHERE id=?",
|
|
672
939
|
(line_id,),
|
|
673
|
-
commit=True
|
|
940
|
+
commit=True,
|
|
674
941
|
)
|
|
675
942
|
deleted_count += 1
|
|
676
943
|
except Exception as e:
|
|
677
944
|
logger.warning(f"Failed to delete line {line_id}: {e}")
|
|
678
|
-
|
|
679
|
-
logger.info(
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
945
|
+
|
|
946
|
+
logger.info(
|
|
947
|
+
f"Deleted {deleted_count} lines using pattern: {regex_pattern or exact_text}"
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
# Trigger stats rollup after successful deletion
|
|
951
|
+
if deleted_count > 0:
|
|
952
|
+
try:
|
|
953
|
+
logger.info("Triggering stats rollup after text line deletion")
|
|
954
|
+
run_daily_rollup()
|
|
955
|
+
except Exception as rollup_error:
|
|
956
|
+
logger.error(f"Stats rollup failed after text line deletion: {rollup_error}")
|
|
957
|
+
# Don't fail the deletion operation if rollup fails
|
|
958
|
+
|
|
959
|
+
return jsonify(
|
|
960
|
+
{
|
|
961
|
+
"deleted_count": deleted_count,
|
|
962
|
+
"message": f"Successfully deleted {deleted_count} lines",
|
|
963
|
+
}
|
|
964
|
+
), 200
|
|
965
|
+
|
|
686
966
|
except Exception as e:
|
|
687
967
|
logger.error(f"Error in delete text lines: {e}")
|
|
688
|
-
return jsonify({
|
|
968
|
+
return jsonify({"error": f"Deletion failed: {str(e)}"}), 500
|
|
689
969
|
|
|
690
|
-
@app.route(
|
|
970
|
+
@app.route("/api/preview-deduplication", methods=["POST"])
|
|
691
971
|
def api_preview_deduplication():
|
|
692
972
|
"""
|
|
693
973
|
Preview duplicate sentences that would be removed based on time window and game selection.
|
|
@@ -696,42 +976,44 @@ def register_database_api_routes(app):
|
|
|
696
976
|
try:
|
|
697
977
|
data = request.get_json()
|
|
698
978
|
if not data:
|
|
699
|
-
return jsonify({
|
|
700
|
-
|
|
701
|
-
games = data.get(
|
|
702
|
-
time_window_minutes = data.get(
|
|
703
|
-
case_sensitive = data.get(
|
|
704
|
-
ignore_time_window = data.get(
|
|
705
|
-
|
|
979
|
+
return jsonify({"error": "No data provided"}), 400
|
|
980
|
+
|
|
981
|
+
games = data.get("games", [])
|
|
982
|
+
time_window_minutes = data.get("time_window_minutes", 5)
|
|
983
|
+
case_sensitive = data.get("case_sensitive", False)
|
|
984
|
+
ignore_time_window = data.get("ignore_time_window", False)
|
|
985
|
+
|
|
706
986
|
if not games:
|
|
707
|
-
return jsonify({
|
|
708
|
-
|
|
987
|
+
return jsonify({"error": "At least one game must be selected"}), 400
|
|
988
|
+
|
|
709
989
|
# Get lines from selected games
|
|
710
|
-
if
|
|
990
|
+
if "all" in games:
|
|
711
991
|
all_lines = GameLinesTable.all()
|
|
712
992
|
else:
|
|
713
993
|
all_lines = []
|
|
714
994
|
for game_name in games:
|
|
715
995
|
game_lines = GameLinesTable.get_all_lines_for_scene(game_name)
|
|
716
996
|
all_lines.extend(game_lines)
|
|
717
|
-
|
|
997
|
+
|
|
718
998
|
if not all_lines:
|
|
719
|
-
return jsonify(
|
|
720
|
-
|
|
999
|
+
return jsonify(
|
|
1000
|
+
{"duplicates_count": 0, "games_affected": 0, "samples": []}
|
|
1001
|
+
), 200
|
|
1002
|
+
|
|
721
1003
|
# Group lines by game and sort by timestamp
|
|
722
1004
|
game_lines = defaultdict(list)
|
|
723
1005
|
for line in all_lines:
|
|
724
1006
|
game_name = line.game_name or "Unknown Game"
|
|
725
1007
|
game_lines[game_name].append(line)
|
|
726
|
-
|
|
1008
|
+
|
|
727
1009
|
# Sort lines within each game by timestamp
|
|
728
1010
|
for game_name in game_lines:
|
|
729
1011
|
game_lines[game_name].sort(key=lambda x: float(x.timestamp))
|
|
730
|
-
|
|
1012
|
+
|
|
731
1013
|
duplicates_to_remove = []
|
|
732
1014
|
duplicate_samples = {}
|
|
733
1015
|
time_window_seconds = time_window_minutes * 60
|
|
734
|
-
|
|
1016
|
+
|
|
735
1017
|
# Find duplicates for each game
|
|
736
1018
|
for game_name, lines in game_lines.items():
|
|
737
1019
|
if ignore_time_window:
|
|
@@ -740,73 +1022,85 @@ def register_database_api_routes(app):
|
|
|
740
1022
|
for line in lines:
|
|
741
1023
|
if not line.line_text or not line.line_text.strip():
|
|
742
1024
|
continue
|
|
743
|
-
|
|
744
|
-
line_text =
|
|
745
|
-
|
|
1025
|
+
|
|
1026
|
+
line_text = (
|
|
1027
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
1028
|
+
)
|
|
1029
|
+
|
|
746
1030
|
if line_text in seen_texts:
|
|
747
1031
|
# Found duplicate
|
|
748
1032
|
duplicates_to_remove.append(line.id)
|
|
749
|
-
|
|
1033
|
+
|
|
750
1034
|
# Store sample for preview
|
|
751
1035
|
if line_text not in duplicate_samples:
|
|
752
1036
|
duplicate_samples[line_text] = {
|
|
753
|
-
|
|
754
|
-
|
|
1037
|
+
"text": line.line_text, # Original case
|
|
1038
|
+
"occurrences": 1,
|
|
755
1039
|
}
|
|
756
|
-
duplicate_samples[line_text][
|
|
1040
|
+
duplicate_samples[line_text]["occurrences"] += 1
|
|
757
1041
|
else:
|
|
758
1042
|
seen_texts[line_text] = line.id
|
|
759
1043
|
else:
|
|
760
1044
|
# Find duplicates within time window (original logic)
|
|
761
1045
|
text_timeline = []
|
|
762
|
-
|
|
1046
|
+
|
|
763
1047
|
for line in lines:
|
|
764
1048
|
if not line.line_text or not line.line_text.strip():
|
|
765
1049
|
continue
|
|
766
|
-
|
|
767
|
-
line_text =
|
|
1050
|
+
|
|
1051
|
+
line_text = (
|
|
1052
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
1053
|
+
)
|
|
768
1054
|
timestamp = float(line.timestamp)
|
|
769
|
-
|
|
1055
|
+
|
|
770
1056
|
# Check for duplicates within time window
|
|
771
|
-
for prev_text, prev_timestamp, prev_line_id in reversed(
|
|
1057
|
+
for prev_text, prev_timestamp, prev_line_id in reversed(
|
|
1058
|
+
text_timeline
|
|
1059
|
+
):
|
|
772
1060
|
if timestamp - prev_timestamp > time_window_seconds:
|
|
773
1061
|
break # Outside time window
|
|
774
|
-
|
|
1062
|
+
|
|
775
1063
|
if prev_text == line_text:
|
|
776
1064
|
# Found duplicate within time window
|
|
777
1065
|
duplicates_to_remove.append(line.id)
|
|
778
|
-
|
|
1066
|
+
|
|
779
1067
|
# Store sample for preview
|
|
780
1068
|
if line_text not in duplicate_samples:
|
|
781
1069
|
duplicate_samples[line_text] = {
|
|
782
|
-
|
|
783
|
-
|
|
1070
|
+
"text": line.line_text, # Original case
|
|
1071
|
+
"occurrences": 1,
|
|
784
1072
|
}
|
|
785
|
-
duplicate_samples[line_text][
|
|
1073
|
+
duplicate_samples[line_text]["occurrences"] += 1
|
|
786
1074
|
break
|
|
787
|
-
|
|
1075
|
+
|
|
788
1076
|
text_timeline.append((line_text, timestamp, line.id))
|
|
789
|
-
|
|
1077
|
+
|
|
790
1078
|
# Calculate statistics
|
|
791
1079
|
duplicates_count = len(duplicates_to_remove)
|
|
792
|
-
games_affected = len(
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1080
|
+
games_affected = len(
|
|
1081
|
+
[
|
|
1082
|
+
game
|
|
1083
|
+
for game in game_lines.keys()
|
|
1084
|
+
if any(line.id in duplicates_to_remove for line in game_lines[game])
|
|
1085
|
+
]
|
|
1086
|
+
)
|
|
1087
|
+
|
|
796
1088
|
# Get sample duplicates
|
|
797
1089
|
samples = list(duplicate_samples.values())[:10]
|
|
798
|
-
|
|
799
|
-
return jsonify(
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1090
|
+
|
|
1091
|
+
return jsonify(
|
|
1092
|
+
{
|
|
1093
|
+
"duplicates_count": duplicates_count,
|
|
1094
|
+
"games_affected": games_affected,
|
|
1095
|
+
"samples": samples,
|
|
1096
|
+
}
|
|
1097
|
+
), 200
|
|
1098
|
+
|
|
805
1099
|
except Exception as e:
|
|
806
1100
|
logger.error(f"Error in preview deduplication: {e}")
|
|
807
|
-
return jsonify({
|
|
1101
|
+
return jsonify({"error": f"Preview failed: {str(e)}"}), 500
|
|
808
1102
|
|
|
809
|
-
@app.route(
|
|
1103
|
+
@app.route("/api/deduplicate", methods=["POST"])
|
|
810
1104
|
def api_deduplicate():
|
|
811
1105
|
"""
|
|
812
1106
|
Remove duplicate sentences from database based on time window and game selection.
|
|
@@ -815,42 +1109,42 @@ def register_database_api_routes(app):
|
|
|
815
1109
|
try:
|
|
816
1110
|
data = request.get_json()
|
|
817
1111
|
if not data:
|
|
818
|
-
return jsonify({
|
|
819
|
-
|
|
820
|
-
games = data.get(
|
|
821
|
-
time_window_minutes = data.get(
|
|
822
|
-
case_sensitive = data.get(
|
|
823
|
-
preserve_newest = data.get(
|
|
824
|
-
ignore_time_window = data.get(
|
|
825
|
-
|
|
1112
|
+
return jsonify({"error": "No data provided"}), 400
|
|
1113
|
+
|
|
1114
|
+
games = data.get("games", [])
|
|
1115
|
+
time_window_minutes = data.get("time_window_minutes", 5)
|
|
1116
|
+
case_sensitive = data.get("case_sensitive", False)
|
|
1117
|
+
preserve_newest = data.get("preserve_newest", False)
|
|
1118
|
+
ignore_time_window = data.get("ignore_time_window", False)
|
|
1119
|
+
|
|
826
1120
|
if not games:
|
|
827
|
-
return jsonify({
|
|
828
|
-
|
|
1121
|
+
return jsonify({"error": "At least one game must be selected"}), 400
|
|
1122
|
+
|
|
829
1123
|
# Get lines from selected games
|
|
830
|
-
if
|
|
1124
|
+
if "all" in games:
|
|
831
1125
|
all_lines = GameLinesTable.all()
|
|
832
1126
|
else:
|
|
833
1127
|
all_lines = []
|
|
834
1128
|
for game_name in games:
|
|
835
1129
|
game_lines = GameLinesTable.get_all_lines_for_scene(game_name)
|
|
836
1130
|
all_lines.extend(game_lines)
|
|
837
|
-
|
|
1131
|
+
|
|
838
1132
|
if not all_lines:
|
|
839
|
-
return jsonify({
|
|
840
|
-
|
|
1133
|
+
return jsonify({"deleted_count": 0}), 200
|
|
1134
|
+
|
|
841
1135
|
# Group lines by game and sort by timestamp
|
|
842
1136
|
game_lines = defaultdict(list)
|
|
843
1137
|
for line in all_lines:
|
|
844
1138
|
game_name = line.game_name or "Unknown Game"
|
|
845
1139
|
game_lines[game_name].append(line)
|
|
846
|
-
|
|
1140
|
+
|
|
847
1141
|
# Sort lines within each game by timestamp
|
|
848
1142
|
for game_name in game_lines:
|
|
849
1143
|
game_lines[game_name].sort(key=lambda x: float(x.timestamp))
|
|
850
|
-
|
|
1144
|
+
|
|
851
1145
|
duplicates_to_remove = []
|
|
852
1146
|
time_window_seconds = time_window_minutes * 60
|
|
853
|
-
|
|
1147
|
+
|
|
854
1148
|
# Find duplicates for each game
|
|
855
1149
|
for game_name, lines in game_lines.items():
|
|
856
1150
|
if ignore_time_window:
|
|
@@ -859,9 +1153,11 @@ def register_database_api_routes(app):
|
|
|
859
1153
|
for line in lines:
|
|
860
1154
|
if not line.line_text or not line.line_text.strip():
|
|
861
1155
|
continue
|
|
862
|
-
|
|
863
|
-
line_text =
|
|
864
|
-
|
|
1156
|
+
|
|
1157
|
+
line_text = (
|
|
1158
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
1159
|
+
)
|
|
1160
|
+
|
|
865
1161
|
if line_text in seen_texts:
|
|
866
1162
|
# Found duplicate
|
|
867
1163
|
if preserve_newest:
|
|
@@ -876,20 +1172,24 @@ def register_database_api_routes(app):
|
|
|
876
1172
|
else:
|
|
877
1173
|
# Find duplicates within time window (original logic)
|
|
878
1174
|
text_timeline = []
|
|
879
|
-
|
|
1175
|
+
|
|
880
1176
|
for line in lines:
|
|
881
1177
|
if not line.line_text or not line.line_text.strip():
|
|
882
1178
|
continue
|
|
883
|
-
|
|
884
|
-
line_text =
|
|
1179
|
+
|
|
1180
|
+
line_text = (
|
|
1181
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
1182
|
+
)
|
|
885
1183
|
timestamp = float(line.timestamp)
|
|
886
|
-
|
|
1184
|
+
|
|
887
1185
|
# Check for duplicates within time window
|
|
888
1186
|
duplicate_found = False
|
|
889
|
-
for i, (prev_text, prev_timestamp, prev_line_id) in enumerate(
|
|
1187
|
+
for i, (prev_text, prev_timestamp, prev_line_id) in enumerate(
|
|
1188
|
+
reversed(text_timeline)
|
|
1189
|
+
):
|
|
890
1190
|
if timestamp - prev_timestamp > time_window_seconds:
|
|
891
1191
|
break # Outside time window
|
|
892
|
-
|
|
1192
|
+
|
|
893
1193
|
if prev_text == line_text:
|
|
894
1194
|
# Found duplicate within time window
|
|
895
1195
|
if preserve_newest:
|
|
@@ -897,43 +1197,66 @@ def register_database_api_routes(app):
|
|
|
897
1197
|
duplicates_to_remove.append(prev_line_id)
|
|
898
1198
|
# Update timeline to replace old entry with new one
|
|
899
1199
|
timeline_index = len(text_timeline) - 1 - i
|
|
900
|
-
text_timeline[timeline_index] = (
|
|
1200
|
+
text_timeline[timeline_index] = (
|
|
1201
|
+
line_text,
|
|
1202
|
+
timestamp,
|
|
1203
|
+
line.id,
|
|
1204
|
+
)
|
|
901
1205
|
else:
|
|
902
1206
|
# Remove the newer one (current)
|
|
903
1207
|
duplicates_to_remove.append(line.id)
|
|
904
|
-
|
|
1208
|
+
|
|
905
1209
|
duplicate_found = True
|
|
906
1210
|
break
|
|
907
|
-
|
|
1211
|
+
|
|
908
1212
|
if not duplicate_found:
|
|
909
1213
|
text_timeline.append((line_text, timestamp, line.id))
|
|
910
|
-
|
|
1214
|
+
|
|
911
1215
|
# Delete the duplicate lines
|
|
912
1216
|
deleted_count = 0
|
|
913
|
-
for line_id in set(
|
|
1217
|
+
for line_id in set(
|
|
1218
|
+
duplicates_to_remove
|
|
1219
|
+
): # Remove duplicates from deletion list
|
|
914
1220
|
try:
|
|
915
1221
|
GameLinesTable._db.execute(
|
|
916
1222
|
f"DELETE FROM {GameLinesTable._table} WHERE id=?",
|
|
917
1223
|
(line_id,),
|
|
918
|
-
commit=True
|
|
1224
|
+
commit=True,
|
|
919
1225
|
)
|
|
920
1226
|
deleted_count += 1
|
|
921
1227
|
except Exception as e:
|
|
922
1228
|
logger.warning(f"Failed to delete duplicate line {line_id}: {e}")
|
|
923
|
-
|
|
924
|
-
mode_desc =
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1229
|
+
|
|
1230
|
+
mode_desc = (
|
|
1231
|
+
"entire game"
|
|
1232
|
+
if ignore_time_window
|
|
1233
|
+
else f"{time_window_minutes}min window"
|
|
1234
|
+
)
|
|
1235
|
+
logger.info(
|
|
1236
|
+
f"Deduplication completed: removed {deleted_count} duplicate sentences from {len(games)} games with {mode_desc}"
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
# Trigger stats rollup after successful deduplication
|
|
1240
|
+
if deleted_count > 0:
|
|
1241
|
+
try:
|
|
1242
|
+
logger.info("Triggering stats rollup after deduplication")
|
|
1243
|
+
run_daily_rollup()
|
|
1244
|
+
except Exception as rollup_error:
|
|
1245
|
+
logger.error(f"Stats rollup failed after deduplication: {rollup_error}")
|
|
1246
|
+
# Don't fail the deduplication operation if rollup fails
|
|
1247
|
+
|
|
1248
|
+
return jsonify(
|
|
1249
|
+
{
|
|
1250
|
+
"deleted_count": deleted_count,
|
|
1251
|
+
"message": f"Successfully removed {deleted_count} duplicate sentences",
|
|
1252
|
+
}
|
|
1253
|
+
), 200
|
|
1254
|
+
|
|
932
1255
|
except Exception as e:
|
|
933
1256
|
logger.error(f"Error in deduplication: {e}")
|
|
934
|
-
return jsonify({
|
|
1257
|
+
return jsonify({"error": f"Deduplication failed: {str(e)}"}), 500
|
|
935
1258
|
|
|
936
|
-
@app.route(
|
|
1259
|
+
@app.route("/api/deduplicate-entire-game", methods=["POST"])
|
|
937
1260
|
def api_deduplicate_entire_game():
|
|
938
1261
|
"""
|
|
939
1262
|
Remove duplicate sentences from database across entire games without time window restrictions.
|
|
@@ -942,19 +1265,152 @@ def register_database_api_routes(app):
|
|
|
942
1265
|
try:
|
|
943
1266
|
data = request.get_json()
|
|
944
1267
|
if not data:
|
|
945
|
-
return jsonify({
|
|
946
|
-
|
|
1268
|
+
return jsonify({"error": "No data provided"}), 400
|
|
1269
|
+
|
|
947
1270
|
# Add ignore_time_window=True to the request data
|
|
948
|
-
data[
|
|
949
|
-
|
|
1271
|
+
data["ignore_time_window"] = True
|
|
1272
|
+
|
|
950
1273
|
# Call the main deduplication function
|
|
951
1274
|
return api_deduplicate()
|
|
952
|
-
|
|
1275
|
+
|
|
953
1276
|
except Exception as e:
|
|
954
1277
|
logger.error(f"Error in entire game deduplication: {e}")
|
|
955
|
-
return jsonify(
|
|
1278
|
+
return jsonify(
|
|
1279
|
+
{"error": f"Entire game deduplication failed: {str(e)}"}
|
|
1280
|
+
), 500
|
|
1281
|
+
|
|
1282
|
+
@app.route("/api/search-duplicates", methods=["POST"])
|
|
1283
|
+
def api_search_duplicates():
|
|
1284
|
+
"""
|
|
1285
|
+
Search for duplicate sentences and return full line details for display in search results.
|
|
1286
|
+
Similar to preview-deduplication but returns complete line information with IDs.
|
|
1287
|
+
"""
|
|
1288
|
+
try:
|
|
1289
|
+
data = request.get_json()
|
|
1290
|
+
if not data:
|
|
1291
|
+
return jsonify({"error": "No data provided"}), 400
|
|
1292
|
+
|
|
1293
|
+
game_filter = data.get("game", "")
|
|
1294
|
+
time_window_minutes = data.get("time_window_minutes", 5)
|
|
1295
|
+
case_sensitive = data.get("case_sensitive", False)
|
|
1296
|
+
ignore_time_window = data.get("ignore_time_window", False)
|
|
1297
|
+
|
|
1298
|
+
# Get lines from selected game or all games
|
|
1299
|
+
if game_filter:
|
|
1300
|
+
all_lines = GameLinesTable.get_all_lines_for_scene(game_filter)
|
|
1301
|
+
else:
|
|
1302
|
+
all_lines = GameLinesTable.all()
|
|
1303
|
+
|
|
1304
|
+
if not all_lines:
|
|
1305
|
+
return jsonify({
|
|
1306
|
+
"results": [],
|
|
1307
|
+
"total": 0,
|
|
1308
|
+
"duplicates_found": 0
|
|
1309
|
+
}), 200
|
|
1310
|
+
|
|
1311
|
+
# Group lines by game and sort by timestamp
|
|
1312
|
+
game_lines = defaultdict(list)
|
|
1313
|
+
for line in all_lines:
|
|
1314
|
+
game_name = line.game_name or "Unknown Game"
|
|
1315
|
+
game_lines[game_name].append(line)
|
|
1316
|
+
|
|
1317
|
+
# Sort lines within each game by timestamp
|
|
1318
|
+
for game_name in game_lines:
|
|
1319
|
+
game_lines[game_name].sort(key=lambda x: float(x.timestamp))
|
|
1320
|
+
|
|
1321
|
+
duplicate_line_ids = set()
|
|
1322
|
+
time_window_seconds = time_window_minutes * 60
|
|
1323
|
+
|
|
1324
|
+
# Find duplicates for each game
|
|
1325
|
+
for game_name, lines in game_lines.items():
|
|
1326
|
+
if ignore_time_window:
|
|
1327
|
+
# Find all duplicates regardless of time
|
|
1328
|
+
seen_texts = {}
|
|
1329
|
+
for line in lines:
|
|
1330
|
+
# Ensure line_text is a string
|
|
1331
|
+
if not line.line_text or not isinstance(line.line_text, str):
|
|
1332
|
+
continue
|
|
1333
|
+
if not line.line_text.strip():
|
|
1334
|
+
continue
|
|
1335
|
+
|
|
1336
|
+
line_text = (
|
|
1337
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
if line_text in seen_texts:
|
|
1341
|
+
# Mark this as a duplicate (keep first occurrence)
|
|
1342
|
+
duplicate_line_ids.add(line.id)
|
|
1343
|
+
else:
|
|
1344
|
+
seen_texts[line_text] = line.id
|
|
1345
|
+
else:
|
|
1346
|
+
# Find duplicates within time window
|
|
1347
|
+
text_timeline = []
|
|
1348
|
+
|
|
1349
|
+
for line in lines:
|
|
1350
|
+
# Ensure line_text is a string
|
|
1351
|
+
if not line.line_text or not isinstance(line.line_text, str):
|
|
1352
|
+
continue
|
|
1353
|
+
if not line.line_text.strip():
|
|
1354
|
+
continue
|
|
1355
|
+
|
|
1356
|
+
line_text = (
|
|
1357
|
+
line.line_text if case_sensitive else line.line_text.lower()
|
|
1358
|
+
)
|
|
1359
|
+
timestamp = float(line.timestamp)
|
|
1360
|
+
|
|
1361
|
+
# Check for duplicates within time window
|
|
1362
|
+
for prev_text, prev_timestamp, prev_line_id in reversed(
|
|
1363
|
+
text_timeline
|
|
1364
|
+
):
|
|
1365
|
+
if timestamp - prev_timestamp > time_window_seconds:
|
|
1366
|
+
break # Outside time window
|
|
1367
|
+
|
|
1368
|
+
if prev_text == line_text:
|
|
1369
|
+
# Found duplicate within time window
|
|
1370
|
+
duplicate_line_ids.add(line.id)
|
|
1371
|
+
break
|
|
1372
|
+
|
|
1373
|
+
text_timeline.append((line_text, timestamp, line.id))
|
|
1374
|
+
|
|
1375
|
+
# Get full details for all duplicate lines
|
|
1376
|
+
duplicate_lines = [line for line in all_lines if line.id in duplicate_line_ids]
|
|
1377
|
+
|
|
1378
|
+
# Group duplicates by normalized text for sorting
|
|
1379
|
+
# Sort by: 1) normalized text (to group duplicates), 2) timestamp (oldest first within group)
|
|
1380
|
+
def get_sort_key(line):
|
|
1381
|
+
if not line.line_text or not isinstance(line.line_text, str):
|
|
1382
|
+
return ("", 0)
|
|
1383
|
+
normalized_text = line.line_text.lower() if not case_sensitive else line.line_text
|
|
1384
|
+
timestamp = float(line.timestamp) if line.timestamp else 0
|
|
1385
|
+
return (normalized_text, timestamp)
|
|
1386
|
+
|
|
1387
|
+
duplicate_lines.sort(key=get_sort_key)
|
|
1388
|
+
|
|
1389
|
+
# Format results to match search results format
|
|
1390
|
+
results = []
|
|
1391
|
+
for line in duplicate_lines:
|
|
1392
|
+
results.append({
|
|
1393
|
+
"id": line.id,
|
|
1394
|
+
"sentence": line.line_text or "",
|
|
1395
|
+
"game_name": line.game_name or "Unknown Game",
|
|
1396
|
+
"timestamp": float(line.timestamp) if line.timestamp else 0,
|
|
1397
|
+
"translation": line.translation or None,
|
|
1398
|
+
"has_audio": bool(getattr(line, "audio_path", None)),
|
|
1399
|
+
"has_screenshot": bool(getattr(line, "screenshot_path", None)),
|
|
1400
|
+
})
|
|
956
1401
|
|
|
957
|
-
|
|
1402
|
+
return jsonify({
|
|
1403
|
+
"results": results,
|
|
1404
|
+
"total": len(results),
|
|
1405
|
+
"duplicates_found": len(results),
|
|
1406
|
+
"search_mode": "duplicates"
|
|
1407
|
+
}), 200
|
|
1408
|
+
|
|
1409
|
+
except Exception as e:
|
|
1410
|
+
logger.error(f"Error in search duplicates: {e}")
|
|
1411
|
+
return jsonify({"error": f"Duplicate search failed: {str(e)}"}), 500
|
|
1412
|
+
|
|
1413
|
+
@app.route("/api/merge_games", methods=["POST"])
|
|
958
1414
|
def api_merge_games():
|
|
959
1415
|
"""
|
|
960
1416
|
Merges multiple selected games into a single game entry.
|
|
@@ -963,47 +1419,57 @@ def register_database_api_routes(app):
|
|
|
963
1419
|
"""
|
|
964
1420
|
try:
|
|
965
1421
|
data = request.get_json()
|
|
966
|
-
target_game = data.get(
|
|
967
|
-
games_to_merge = data.get(
|
|
968
|
-
|
|
969
|
-
logger.info(
|
|
970
|
-
|
|
1422
|
+
target_game = data.get("target_game", None)
|
|
1423
|
+
games_to_merge = data.get("games_to_merge", [])
|
|
1424
|
+
|
|
1425
|
+
logger.info(
|
|
1426
|
+
f"Merge request received: target_game='{target_game}', games_to_merge={games_to_merge}"
|
|
1427
|
+
)
|
|
1428
|
+
|
|
971
1429
|
# Validation
|
|
972
1430
|
if not target_game:
|
|
973
|
-
return jsonify({
|
|
1431
|
+
return jsonify({"error": "No target game specified for merging"}), 400
|
|
974
1432
|
|
|
975
1433
|
if not games_to_merge:
|
|
976
|
-
return jsonify({
|
|
977
|
-
|
|
1434
|
+
return jsonify({"error": "No games specified for merging"}), 400
|
|
1435
|
+
|
|
978
1436
|
if not isinstance(games_to_merge, list):
|
|
979
|
-
return jsonify({
|
|
980
|
-
|
|
1437
|
+
return jsonify({"error": "game_names must be a list"}), 400
|
|
1438
|
+
|
|
981
1439
|
if len(games_to_merge) < 1:
|
|
982
|
-
return jsonify(
|
|
1440
|
+
return jsonify(
|
|
1441
|
+
{"error": "At least 1 game must be selected for merging"}
|
|
1442
|
+
), 400
|
|
983
1443
|
|
|
984
1444
|
# Validate that all games exist
|
|
985
1445
|
existing_games = GameLinesTable.get_all_games_with_lines()
|
|
986
|
-
invalid_games = [
|
|
987
|
-
|
|
1446
|
+
invalid_games = [
|
|
1447
|
+
name for name in games_to_merge if name not in existing_games
|
|
1448
|
+
]
|
|
1449
|
+
|
|
988
1450
|
if invalid_games:
|
|
989
|
-
return jsonify(
|
|
990
|
-
|
|
1451
|
+
return jsonify(
|
|
1452
|
+
{"error": f"Games not found: {', '.join(invalid_games)}"}
|
|
1453
|
+
), 400
|
|
1454
|
+
|
|
991
1455
|
# Check for duplicate game names
|
|
992
1456
|
if len(set(games_to_merge)) != len(games_to_merge):
|
|
993
|
-
return jsonify(
|
|
994
|
-
|
|
1457
|
+
return jsonify(
|
|
1458
|
+
{"error": "Duplicate game names found in selection"}
|
|
1459
|
+
), 400
|
|
1460
|
+
|
|
995
1461
|
# Identify primary and secondary games
|
|
996
1462
|
|
|
997
1463
|
# Collect pre-merge statistics
|
|
998
1464
|
primary_lines_before = GameLinesTable.get_all_lines_for_scene(target_game)
|
|
999
1465
|
total_lines_to_merge = 0
|
|
1000
1466
|
merge_summary = {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1467
|
+
"primary_game": target_game,
|
|
1468
|
+
"secondary_games": games_to_merge,
|
|
1469
|
+
"lines_moved": 0,
|
|
1470
|
+
"total_lines_after_merge": 0,
|
|
1005
1471
|
}
|
|
1006
|
-
|
|
1472
|
+
|
|
1007
1473
|
# Calculate lines to be moved and store counts
|
|
1008
1474
|
secondary_game_line_counts = {}
|
|
1009
1475
|
for game_name in games_to_merge:
|
|
@@ -1011,10 +1477,12 @@ def register_database_api_routes(app):
|
|
|
1011
1477
|
line_count = len(secondary_lines)
|
|
1012
1478
|
secondary_game_line_counts[game_name] = line_count
|
|
1013
1479
|
total_lines_to_merge += line_count
|
|
1014
|
-
|
|
1480
|
+
|
|
1015
1481
|
if total_lines_to_merge == 0:
|
|
1016
|
-
return jsonify(
|
|
1017
|
-
|
|
1482
|
+
return jsonify(
|
|
1483
|
+
{"error": "No lines found in secondary games to merge"}
|
|
1484
|
+
), 400
|
|
1485
|
+
|
|
1018
1486
|
# Begin database transaction for merge
|
|
1019
1487
|
try:
|
|
1020
1488
|
# Perform the merge operation within transaction
|
|
@@ -1024,831 +1492,59 @@ def register_database_api_routes(app):
|
|
|
1024
1492
|
# Also set original_game_name to preserve the original title
|
|
1025
1493
|
# Ensure the table name is as expected to prevent SQL injection
|
|
1026
1494
|
if GameLinesTable._table != "game_lines":
|
|
1027
|
-
raise ValueError(
|
|
1495
|
+
raise ValueError(
|
|
1496
|
+
"Unexpected table name in GameLinesTable._table"
|
|
1497
|
+
)
|
|
1028
1498
|
GameLinesTable._db.execute(
|
|
1029
1499
|
"UPDATE game_lines SET game_name=?, original_game_name=COALESCE(original_game_name, ?) WHERE game_name=?",
|
|
1030
1500
|
(target_game, game_name, game_name),
|
|
1031
|
-
commit=True
|
|
1501
|
+
commit=True,
|
|
1032
1502
|
)
|
|
1033
|
-
|
|
1503
|
+
|
|
1034
1504
|
# Add the count we calculated earlier
|
|
1035
1505
|
lines_moved += secondary_game_line_counts[game_name]
|
|
1036
|
-
|
|
1506
|
+
|
|
1037
1507
|
# Update merge summary
|
|
1038
|
-
merge_summary[
|
|
1039
|
-
merge_summary[
|
|
1040
|
-
|
|
1508
|
+
merge_summary["lines_moved"] = lines_moved
|
|
1509
|
+
merge_summary["total_lines_after_merge"] = (
|
|
1510
|
+
len(primary_lines_before) + lines_moved
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1041
1513
|
# Log the successful merge
|
|
1042
|
-
logger.info(
|
|
1514
|
+
logger.info(
|
|
1515
|
+
f"Successfully merged {len(games_to_merge)} games into '{target_game}': moved {lines_moved} lines"
|
|
1516
|
+
)
|
|
1043
1517
|
|
|
1044
1518
|
# Prepare success response
|
|
1045
1519
|
response_data = {
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1520
|
+
"message": f'Successfully merged {len(games_to_merge)} games into "{target_game}"',
|
|
1521
|
+
"primary_game": target_game,
|
|
1522
|
+
"merged_games": games_to_merge,
|
|
1523
|
+
"lines_moved": lines_moved,
|
|
1524
|
+
"total_lines_in_primary": merge_summary["total_lines_after_merge"],
|
|
1525
|
+
"merge_summary": merge_summary,
|
|
1052
1526
|
}
|
|
1053
|
-
|
|
1054
|
-
return jsonify(response_data), 200
|
|
1055
|
-
|
|
1056
|
-
except Exception as db_error:
|
|
1057
|
-
logger.error(f"Database error during game merge: {db_error}", exc_info=True)
|
|
1058
|
-
return jsonify({
|
|
1059
|
-
'error': f'Failed to merge games due to database error: {str(db_error)}'
|
|
1060
|
-
}), 500
|
|
1061
|
-
|
|
1062
|
-
except Exception as e:
|
|
1063
|
-
logger.error(f"Error in game merge API: {e}")
|
|
1064
|
-
return jsonify({'error': f'Game merge failed: {str(e)}'}), 500
|
|
1065
|
-
|
|
1066
|
-
@app.route('/api/stats')
|
|
1067
|
-
def api_stats():
|
|
1068
|
-
"""
|
|
1069
|
-
Provides aggregated, cumulative stats for charting.
|
|
1070
|
-
Accepts optional 'year' parameter to filter heatmap data.
|
|
1071
|
-
"""
|
|
1072
|
-
try:
|
|
1073
|
-
# Get optional year filter parameter
|
|
1074
|
-
filter_year = request.args.get('year', None)
|
|
1075
|
-
|
|
1076
|
-
# Get Start and End time as unix timestamp
|
|
1077
|
-
start_timestamp = request.args.get('start', None)
|
|
1078
|
-
end_timestamp = request.args.get('end', None)
|
|
1079
|
-
|
|
1080
|
-
# Convert timestamps to float if provided
|
|
1081
|
-
start_timestamp = float(start_timestamp) if start_timestamp else None
|
|
1082
|
-
end_timestamp = float(end_timestamp) if end_timestamp else None
|
|
1083
|
-
|
|
1084
|
-
# 1. Fetch all lines and sort them chronologically
|
|
1085
|
-
all_lines = GameLinesTable.get_lines_filtered_by_timestamp(start=start_timestamp, end=end_timestamp, for_stats=True)
|
|
1086
|
-
|
|
1087
|
-
if not all_lines:
|
|
1088
|
-
return jsonify({"labels": [], "datasets": []})
|
|
1089
|
-
|
|
1090
|
-
# 2. Process data into daily totals for each game
|
|
1091
|
-
# Structure: daily_data[date_str][game_name] = {'lines': N, 'chars': N}
|
|
1092
|
-
daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
|
|
1093
|
-
wrong_instance_found = False
|
|
1094
|
-
for line in all_lines:
|
|
1095
|
-
day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
|
1096
|
-
game = line.game_name or "Unknown Game"
|
|
1097
|
-
# Remove punctuation and symbols from line text before counting characters
|
|
1098
|
-
if not isinstance(line.line_text, str) and not wrong_instance_found:
|
|
1099
|
-
logger.info(f"Non-string line_text encountered: {line.line_text} (type: {type(line.line_text)})")
|
|
1100
|
-
wrong_instance_found = True
|
|
1101
|
-
|
|
1102
|
-
daily_data[day_str][game]['lines'] += 1
|
|
1103
|
-
daily_data[day_str][game]['chars'] += len(line.line_text)
|
|
1104
|
-
|
|
1105
|
-
# 3. Create cumulative datasets for Chart.js
|
|
1106
|
-
sorted_days = sorted(daily_data.keys())
|
|
1107
|
-
game_names = GameLinesTable.get_all_games_with_lines()
|
|
1108
|
-
|
|
1109
|
-
# Keep track of the running total for each metric for each game
|
|
1110
|
-
cumulative_totals = defaultdict(lambda: {'lines': 0, 'chars': 0})
|
|
1111
|
-
|
|
1112
|
-
# Structure for final data: final_data[game_name][metric] = [day1_val, day2_val, ...]
|
|
1113
|
-
final_data = defaultdict(lambda: defaultdict(list))
|
|
1114
|
-
|
|
1115
|
-
for day in sorted_days:
|
|
1116
|
-
for game in game_names:
|
|
1117
|
-
# Add the day's total to the cumulative total
|
|
1118
|
-
cumulative_totals[game]['lines'] += daily_data[day][game]['lines']
|
|
1119
|
-
cumulative_totals[game]['chars'] += daily_data[day][game]['chars']
|
|
1120
|
-
|
|
1121
|
-
# Append the new cumulative total to the list for that day
|
|
1122
|
-
final_data[game]['lines'].append(cumulative_totals[game]['lines'])
|
|
1123
|
-
final_data[game]['chars'].append(cumulative_totals[game]['chars'])
|
|
1124
|
-
|
|
1125
|
-
# 4. Format into Chart.js dataset structure
|
|
1126
|
-
datasets = []
|
|
1127
|
-
# A simple color palette for the chart lines
|
|
1128
|
-
colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
|
|
1129
|
-
|
|
1130
|
-
for i, game in enumerate(game_names):
|
|
1131
|
-
color = colors[i % len(colors)]
|
|
1132
|
-
|
|
1133
|
-
# Note: We already have filtered data in all_lines from line 965, so we don't need to fetch again
|
|
1134
|
-
# The duplicate data fetching that was here has been removed to fix the date range filtering issue
|
|
1135
|
-
|
|
1136
|
-
# 4. Format into Chart.js dataset structure
|
|
1137
|
-
try:
|
|
1138
|
-
datasets = []
|
|
1139
|
-
# A simple color palette for the chart lines
|
|
1140
|
-
colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
|
|
1141
|
-
|
|
1142
|
-
for i, game in enumerate(game_names):
|
|
1143
|
-
color = colors[i % len(colors)]
|
|
1144
|
-
|
|
1145
|
-
datasets.append({
|
|
1146
|
-
"label": f"{game}",
|
|
1147
|
-
"data": final_data[game]['lines'],
|
|
1148
|
-
"borderColor": color,
|
|
1149
|
-
"backgroundColor": f"{color}33", # Semi-transparent for fill
|
|
1150
|
-
"fill": False,
|
|
1151
|
-
"tension": 0.1,
|
|
1152
|
-
"for": "Lines Received"
|
|
1153
|
-
})
|
|
1154
|
-
datasets.append({
|
|
1155
|
-
"label": f"{game}",
|
|
1156
|
-
"data": final_data[game]['chars'],
|
|
1157
|
-
"borderColor": color,
|
|
1158
|
-
"backgroundColor": f"{color}33",
|
|
1159
|
-
"fill": False,
|
|
1160
|
-
"tension": 0.1,
|
|
1161
|
-
"hidden": True, # Hide by default to not clutter the chart
|
|
1162
|
-
"for": "Characters Read"
|
|
1163
|
-
})
|
|
1164
|
-
except Exception as e:
|
|
1165
|
-
logger.error(f"Error formatting Chart.js datasets: {e}")
|
|
1166
|
-
return jsonify({'error': 'Failed to format chart data'}), 500
|
|
1167
|
-
|
|
1168
|
-
# 5. Calculate additional chart data
|
|
1169
|
-
try:
|
|
1170
|
-
kanji_grid_data = calculate_kanji_frequency(all_lines)
|
|
1171
|
-
except Exception as e:
|
|
1172
|
-
logger.error(f"Error calculating kanji frequency: {e}")
|
|
1173
|
-
kanji_grid_data = []
|
|
1174
|
-
|
|
1175
|
-
try:
|
|
1176
|
-
heatmap_data = calculate_heatmap_data(all_lines, filter_year)
|
|
1177
|
-
except Exception as e:
|
|
1178
|
-
logger.error(f"Error calculating heatmap data: {e}")
|
|
1179
|
-
heatmap_data = []
|
|
1180
|
-
|
|
1181
|
-
try:
|
|
1182
|
-
total_chars_data = calculate_total_chars_per_game(all_lines)
|
|
1183
|
-
except Exception as e:
|
|
1184
|
-
logger.error(f"Error calculating total chars per game: {e}")
|
|
1185
|
-
total_chars_data = {}
|
|
1186
|
-
|
|
1187
|
-
try:
|
|
1188
|
-
reading_time_data = calculate_reading_time_per_game(all_lines)
|
|
1189
|
-
except Exception as e:
|
|
1190
|
-
logger.error(f"Error calculating reading time per game: {e}")
|
|
1191
|
-
reading_time_data = {}
|
|
1192
|
-
|
|
1193
|
-
try:
|
|
1194
|
-
reading_speed_per_game_data = calculate_reading_speed_per_game(all_lines)
|
|
1195
|
-
except Exception as e:
|
|
1196
|
-
logger.error(f"Error calculating reading speed per game: {e}")
|
|
1197
|
-
reading_speed_per_game_data = {}
|
|
1198
|
-
|
|
1199
|
-
# 6. Calculate dashboard statistics
|
|
1200
|
-
try:
|
|
1201
|
-
current_game_stats = calculate_current_game_stats(all_lines)
|
|
1202
|
-
except Exception as e:
|
|
1203
|
-
logger.error(f"Error calculating current game stats: {e}")
|
|
1204
|
-
current_game_stats = {}
|
|
1205
|
-
|
|
1206
|
-
try:
|
|
1207
|
-
all_games_stats = calculate_all_games_stats(all_lines)
|
|
1208
|
-
except Exception as e:
|
|
1209
|
-
logger.error(f"Error calculating all games stats: {e}")
|
|
1210
|
-
all_games_stats = {}
|
|
1211
|
-
|
|
1212
|
-
# 7. Prepare allLinesData for frontend calculations (needed for average daily time)
|
|
1213
|
-
try:
|
|
1214
|
-
all_lines_data = []
|
|
1215
|
-
for line in all_lines:
|
|
1216
|
-
all_lines_data.append({
|
|
1217
|
-
'timestamp': float(line.timestamp),
|
|
1218
|
-
'game_name': line.game_name or 'Unknown Game',
|
|
1219
|
-
'characters': len(line.line_text) if line.line_text else 0,
|
|
1220
|
-
'id': line.id
|
|
1221
|
-
})
|
|
1222
|
-
except Exception as e:
|
|
1223
|
-
logger.error(f"Error preparing all lines data: {e}")
|
|
1224
|
-
all_lines_data = []
|
|
1225
|
-
|
|
1226
|
-
# 8. Calculate hourly activity pattern
|
|
1227
|
-
try:
|
|
1228
|
-
hourly_activity_data = calculate_hourly_activity(all_lines)
|
|
1229
|
-
except Exception as e:
|
|
1230
|
-
logger.error(f"Error calculating hourly activity: {e}")
|
|
1231
|
-
hourly_activity_data = [0] * 24
|
|
1232
|
-
|
|
1233
|
-
# 8.5. Calculate hourly reading speed pattern
|
|
1234
|
-
try:
|
|
1235
|
-
hourly_reading_speed_data = calculate_hourly_reading_speed(all_lines)
|
|
1236
|
-
except Exception as e:
|
|
1237
|
-
logger.error(f"Error calculating hourly reading speed: {e}")
|
|
1238
|
-
hourly_reading_speed_data = [0] * 24
|
|
1239
|
-
|
|
1240
|
-
# 9. Calculate peak statistics
|
|
1241
|
-
try:
|
|
1242
|
-
peak_daily_stats = calculate_peak_daily_stats(all_lines)
|
|
1243
|
-
except Exception as e:
|
|
1244
|
-
logger.error(f"Error calculating peak daily stats: {e}")
|
|
1245
|
-
peak_daily_stats = {'max_daily_chars': 0, 'max_daily_hours': 0.0}
|
|
1246
|
-
|
|
1247
|
-
try:
|
|
1248
|
-
peak_session_stats = calculate_peak_session_stats(all_lines)
|
|
1249
|
-
except Exception as e:
|
|
1250
|
-
logger.error(f"Error calculating peak session stats: {e}")
|
|
1251
|
-
peak_session_stats = {'longest_session_hours': 0.0, 'max_session_chars': 0}
|
|
1252
|
-
|
|
1253
|
-
return jsonify({
|
|
1254
|
-
"labels": sorted_days,
|
|
1255
|
-
"datasets": datasets,
|
|
1256
|
-
"kanjiGridData": kanji_grid_data,
|
|
1257
|
-
"heatmapData": heatmap_data,
|
|
1258
|
-
"totalCharsPerGame": total_chars_data,
|
|
1259
|
-
"readingTimePerGame": reading_time_data,
|
|
1260
|
-
"readingSpeedPerGame": reading_speed_per_game_data,
|
|
1261
|
-
"currentGameStats": current_game_stats,
|
|
1262
|
-
"allGamesStats": all_games_stats,
|
|
1263
|
-
"allLinesData": all_lines_data,
|
|
1264
|
-
"hourlyActivityData": hourly_activity_data,
|
|
1265
|
-
"hourlyReadingSpeedData": hourly_reading_speed_data,
|
|
1266
|
-
"peakDailyStats": peak_daily_stats,
|
|
1267
|
-
"peakSessionStats": peak_session_stats
|
|
1268
|
-
})
|
|
1269
|
-
|
|
1270
|
-
except Exception as e:
|
|
1271
|
-
logger.error(f"Unexpected error in api_stats: {e}", exc_info=True)
|
|
1272
|
-
return jsonify({'error': 'Failed to generate statistics'}), 500
|
|
1273
|
-
|
|
1274
|
-
@app.route('/api/mining_heatmap')
|
|
1275
|
-
def api_mining_heatmap():
|
|
1276
|
-
"""
|
|
1277
|
-
Provides mining heatmap data showing daily mining activity.
|
|
1278
|
-
Counts lines where screenshot_in_anki OR audio_in_anki is not empty.
|
|
1279
|
-
Accepts optional 'start' and 'end' timestamp parameters for filtering.
|
|
1280
|
-
"""
|
|
1281
|
-
try:
|
|
1282
|
-
# Get optional timestamp filter parameters
|
|
1283
|
-
start_timestamp = request.args.get('start', None)
|
|
1284
|
-
end_timestamp = request.args.get('end', None)
|
|
1285
|
-
|
|
1286
|
-
# Convert timestamps to float if provided
|
|
1287
|
-
start_timestamp = float(start_timestamp) if start_timestamp else None
|
|
1288
|
-
end_timestamp = float(end_timestamp) if end_timestamp else None
|
|
1289
|
-
|
|
1290
|
-
# Fetch lines filtered by timestamp
|
|
1291
|
-
all_lines = GameLinesTable.get_lines_filtered_by_timestamp(start=start_timestamp, end=end_timestamp)
|
|
1292
|
-
|
|
1293
|
-
if not all_lines:
|
|
1294
|
-
return jsonify({}), 200
|
|
1295
|
-
|
|
1296
|
-
# Calculate mining heatmap data
|
|
1297
|
-
try:
|
|
1298
|
-
heatmap_data = calculate_mining_heatmap_data(all_lines)
|
|
1299
|
-
except Exception as e:
|
|
1300
|
-
logger.error(f"Error calculating mining heatmap data: {e}")
|
|
1301
|
-
return jsonify({'error': 'Failed to calculate mining heatmap'}), 500
|
|
1302
|
-
|
|
1303
|
-
return jsonify(heatmap_data), 200
|
|
1304
|
-
|
|
1305
|
-
except Exception as e:
|
|
1306
|
-
logger.error(f"Unexpected error in api_mining_heatmap: {e}", exc_info=True)
|
|
1307
|
-
return jsonify({'error': 'Failed to generate mining heatmap'}), 500
|
|
1308
|
-
|
|
1309
|
-
@app.route('/api/goals-today', methods=['GET'])
|
|
1310
|
-
def api_goals_today():
|
|
1311
|
-
"""
|
|
1312
|
-
Calculate daily requirements and current progress for today based on goal target dates.
|
|
1313
|
-
Returns what needs to be accomplished today to stay on track.
|
|
1314
|
-
"""
|
|
1315
|
-
try:
|
|
1316
|
-
config = get_stats_config()
|
|
1317
|
-
today = datetime.date.today()
|
|
1318
|
-
|
|
1319
|
-
# Get all lines for overall progress
|
|
1320
|
-
all_lines = GameLinesTable.all(for_stats=True)
|
|
1321
|
-
if not all_lines:
|
|
1322
|
-
return jsonify({
|
|
1323
|
-
'hours': {'required': 0, 'progress': 0, 'has_target': False},
|
|
1324
|
-
'characters': {'required': 0, 'progress': 0, 'has_target': False},
|
|
1325
|
-
'games': {'required': 0, 'progress': 0, 'has_target': False}
|
|
1326
|
-
}), 200
|
|
1327
|
-
|
|
1328
|
-
# Calculate overall current progress
|
|
1329
|
-
timestamps = [float(line.timestamp) for line in all_lines]
|
|
1330
|
-
total_time_seconds = calculate_actual_reading_time(timestamps)
|
|
1331
|
-
total_hours = total_time_seconds / 3600
|
|
1332
|
-
total_characters = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
|
|
1333
|
-
total_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
|
|
1334
|
-
|
|
1335
|
-
# Get today's lines for progress
|
|
1336
|
-
today_lines = [line for line in all_lines
|
|
1337
|
-
if datetime.date.fromtimestamp(float(line.timestamp)) == today]
|
|
1338
|
-
|
|
1339
|
-
today_timestamps = [float(line.timestamp) for line in today_lines]
|
|
1340
|
-
today_time_seconds = calculate_actual_reading_time(today_timestamps) if len(today_timestamps) >= 2 else 0
|
|
1341
|
-
today_hours = today_time_seconds / 3600
|
|
1342
|
-
today_characters = sum(len(line.line_text) if line.line_text else 0 for line in today_lines)
|
|
1343
|
-
|
|
1344
|
-
result = {}
|
|
1345
|
-
|
|
1346
|
-
# Calculate hours requirement
|
|
1347
|
-
if config.reading_hours_target_date:
|
|
1348
|
-
try:
|
|
1349
|
-
target_date = datetime.datetime.strptime(config.reading_hours_target_date, '%Y-%m-%d').date()
|
|
1350
|
-
days_remaining = (target_date - today).days + 1 # +1 to include today
|
|
1351
|
-
if days_remaining > 0:
|
|
1352
|
-
hours_needed = max(0, config.reading_hours_target - total_hours)
|
|
1353
|
-
daily_hours_required = hours_needed / days_remaining
|
|
1354
|
-
result['hours'] = {
|
|
1355
|
-
'required': round(daily_hours_required, 2),
|
|
1356
|
-
'progress': round(today_hours, 2),
|
|
1357
|
-
'has_target': True,
|
|
1358
|
-
'target_date': config.reading_hours_target_date,
|
|
1359
|
-
'days_remaining': days_remaining
|
|
1360
|
-
}
|
|
1361
|
-
else:
|
|
1362
|
-
result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': True, 'expired': True}
|
|
1363
|
-
except ValueError:
|
|
1364
|
-
result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': False}
|
|
1365
|
-
else:
|
|
1366
|
-
result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': False}
|
|
1367
|
-
|
|
1368
|
-
# Calculate characters requirement
|
|
1369
|
-
if config.character_count_target_date:
|
|
1370
|
-
try:
|
|
1371
|
-
target_date = datetime.datetime.strptime(config.character_count_target_date, '%Y-%m-%d').date()
|
|
1372
|
-
days_remaining = (target_date - today).days + 1
|
|
1373
|
-
if days_remaining > 0:
|
|
1374
|
-
chars_needed = max(0, config.character_count_target - total_characters)
|
|
1375
|
-
daily_chars_required = int(chars_needed / days_remaining)
|
|
1376
|
-
result['characters'] = {
|
|
1377
|
-
'required': daily_chars_required,
|
|
1378
|
-
'progress': today_characters,
|
|
1379
|
-
'has_target': True,
|
|
1380
|
-
'target_date': config.character_count_target_date,
|
|
1381
|
-
'days_remaining': days_remaining
|
|
1382
|
-
}
|
|
1383
|
-
else:
|
|
1384
|
-
result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': True, 'expired': True}
|
|
1385
|
-
except ValueError:
|
|
1386
|
-
result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': False}
|
|
1387
|
-
else:
|
|
1388
|
-
result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': False}
|
|
1389
|
-
|
|
1390
|
-
# Calculate games requirement
|
|
1391
|
-
if config.games_target_date:
|
|
1392
|
-
try:
|
|
1393
|
-
target_date = datetime.datetime.strptime(config.games_target_date, '%Y-%m-%d').date()
|
|
1394
|
-
days_remaining = (target_date - today).days + 1
|
|
1395
|
-
if days_remaining > 0:
|
|
1396
|
-
games_needed = max(0, config.games_target - total_games)
|
|
1397
|
-
daily_games_required = games_needed / days_remaining
|
|
1398
|
-
result['games'] = {
|
|
1399
|
-
'required': round(daily_games_required, 2),
|
|
1400
|
-
'progress': total_games,
|
|
1401
|
-
'has_target': True,
|
|
1402
|
-
'target_date': config.games_target_date,
|
|
1403
|
-
'days_remaining': days_remaining
|
|
1404
|
-
}
|
|
1405
|
-
else:
|
|
1406
|
-
result['games'] = {'required': 0, 'progress': total_games, 'has_target': True, 'expired': True}
|
|
1407
|
-
except ValueError:
|
|
1408
|
-
result['games'] = {'required': 0, 'progress': total_games, 'has_target': False}
|
|
1409
|
-
else:
|
|
1410
|
-
result['games'] = {'required': 0, 'progress': total_games, 'has_target': False}
|
|
1411
|
-
|
|
1412
|
-
return jsonify(result), 200
|
|
1413
|
-
|
|
1414
|
-
except Exception as e:
|
|
1415
|
-
logger.error(f"Error calculating goals today: {e}")
|
|
1416
|
-
return jsonify({'error': 'Failed to calculate daily goals'}), 500
|
|
1417
|
-
|
|
1418
|
-
@app.route('/api/goals-projection', methods=['GET'])
|
|
1419
|
-
def api_goals_projection():
|
|
1420
|
-
"""
|
|
1421
|
-
Calculate projections based on 30-day rolling average.
|
|
1422
|
-
Returns projected stats by target dates.
|
|
1423
|
-
"""
|
|
1424
|
-
try:
|
|
1425
|
-
config = get_stats_config()
|
|
1426
|
-
today = datetime.date.today()
|
|
1427
|
-
thirty_days_ago = today - datetime.timedelta(days=30)
|
|
1428
|
-
|
|
1429
|
-
# Get all lines
|
|
1430
|
-
all_lines = GameLinesTable.all(for_stats=True)
|
|
1431
|
-
if not all_lines:
|
|
1432
|
-
return jsonify({
|
|
1433
|
-
'hours': {'projection': 0, 'daily_average': 0},
|
|
1434
|
-
'characters': {'projection': 0, 'daily_average': 0},
|
|
1435
|
-
'games': {'projection': 0, 'daily_average': 0}
|
|
1436
|
-
}), 200
|
|
1437
|
-
|
|
1438
|
-
# Get last 30 days of lines
|
|
1439
|
-
recent_lines = [line for line in all_lines
|
|
1440
|
-
if datetime.date.fromtimestamp(float(line.timestamp)) >= thirty_days_ago]
|
|
1441
|
-
|
|
1442
|
-
# Calculate 30-day averages
|
|
1443
|
-
if recent_lines:
|
|
1444
|
-
# Group by day for accurate averaging
|
|
1445
|
-
daily_data = defaultdict(lambda: {'timestamps': [], 'characters': 0, 'games': set()})
|
|
1446
|
-
for line in recent_lines:
|
|
1447
|
-
day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
|
|
1448
|
-
daily_data[day_str]['timestamps'].append(float(line.timestamp))
|
|
1449
|
-
daily_data[day_str]['characters'] += len(line.line_text) if line.line_text else 0
|
|
1450
|
-
daily_data[day_str]['games'].add(line.game_name or "Unknown Game")
|
|
1451
|
-
|
|
1452
|
-
# Calculate daily averages
|
|
1453
|
-
total_hours = 0
|
|
1454
|
-
total_chars = 0
|
|
1455
|
-
total_unique_games = set()
|
|
1456
|
-
|
|
1457
|
-
for day_data in daily_data.values():
|
|
1458
|
-
if len(day_data['timestamps']) >= 2:
|
|
1459
|
-
day_seconds = calculate_actual_reading_time(day_data['timestamps'])
|
|
1460
|
-
total_hours += day_seconds / 3600
|
|
1461
|
-
total_chars += day_data['characters']
|
|
1462
|
-
total_unique_games.update(day_data['games'])
|
|
1463
|
-
|
|
1464
|
-
# Average over ALL 30 days (including days with 0 activity)
|
|
1465
|
-
avg_daily_hours = total_hours / 30
|
|
1466
|
-
avg_daily_chars = total_chars / 30
|
|
1467
|
-
# Calculate average daily unique games correctly
|
|
1468
|
-
today = datetime.date.today()
|
|
1469
|
-
daily_unique_games_counts = []
|
|
1470
|
-
for i in range(30):
|
|
1471
|
-
day = (today - datetime.timedelta(days=i)).strftime('%Y-%m-%d')
|
|
1472
|
-
daily_unique_games_counts.append(len(daily_data[day]['games']) if day in daily_data else 0)
|
|
1473
|
-
avg_daily_games = sum(daily_unique_games_counts) / 30
|
|
1474
|
-
else:
|
|
1475
|
-
avg_daily_hours = 0
|
|
1476
|
-
avg_daily_chars = 0
|
|
1477
|
-
avg_daily_games = 0
|
|
1478
|
-
|
|
1479
|
-
# Calculate current totals
|
|
1480
|
-
timestamps = [float(line.timestamp) for line in all_lines]
|
|
1481
|
-
current_hours = calculate_actual_reading_time(timestamps) / 3600
|
|
1482
|
-
current_chars = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
|
|
1483
|
-
current_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
|
|
1484
|
-
|
|
1485
|
-
result = {}
|
|
1486
|
-
|
|
1487
|
-
# Project hours by target date
|
|
1488
|
-
if config.reading_hours_target_date:
|
|
1489
|
-
try:
|
|
1490
|
-
target_date = datetime.datetime.strptime(config.reading_hours_target_date, '%Y-%m-%d').date()
|
|
1491
|
-
days_until_target = (target_date - today).days
|
|
1492
|
-
projected_hours = current_hours + (avg_daily_hours * days_until_target)
|
|
1493
|
-
result['hours'] = {
|
|
1494
|
-
'projection': round(projected_hours, 2),
|
|
1495
|
-
'daily_average': round(avg_daily_hours, 2),
|
|
1496
|
-
'target_date': config.reading_hours_target_date,
|
|
1497
|
-
'target': config.reading_hours_target,
|
|
1498
|
-
'current': round(current_hours, 2)
|
|
1499
|
-
}
|
|
1500
|
-
except ValueError:
|
|
1501
|
-
result['hours'] = {'projection': 0, 'daily_average': round(avg_daily_hours, 2)}
|
|
1502
|
-
else:
|
|
1503
|
-
result['hours'] = {'projection': 0, 'daily_average': round(avg_daily_hours, 2)}
|
|
1504
|
-
|
|
1505
|
-
# Project characters by target date
|
|
1506
|
-
if config.character_count_target_date:
|
|
1507
|
-
try:
|
|
1508
|
-
target_date = datetime.datetime.strptime(config.character_count_target_date, '%Y-%m-%d').date()
|
|
1509
|
-
days_until_target = (target_date - today).days
|
|
1510
|
-
projected_chars = int(current_chars + (avg_daily_chars * days_until_target))
|
|
1511
|
-
result['characters'] = {
|
|
1512
|
-
'projection': projected_chars,
|
|
1513
|
-
'daily_average': int(avg_daily_chars),
|
|
1514
|
-
'target_date': config.character_count_target_date,
|
|
1515
|
-
'target': config.character_count_target,
|
|
1516
|
-
'current': current_chars
|
|
1517
|
-
}
|
|
1518
|
-
except ValueError:
|
|
1519
|
-
result['characters'] = {'projection': 0, 'daily_average': int(avg_daily_chars)}
|
|
1520
|
-
else:
|
|
1521
|
-
result['characters'] = {'projection': 0, 'daily_average': int(avg_daily_chars)}
|
|
1522
|
-
|
|
1523
|
-
# Project games by target date
|
|
1524
|
-
if config.games_target_date:
|
|
1525
|
-
try:
|
|
1526
|
-
target_date = datetime.datetime.strptime(config.games_target_date, '%Y-%m-%d').date()
|
|
1527
|
-
days_until_target = (target_date - today).days
|
|
1528
|
-
projected_games = int(current_games + (avg_daily_games * days_until_target))
|
|
1529
|
-
result['games'] = {
|
|
1530
|
-
'projection': projected_games,
|
|
1531
|
-
'daily_average': round(avg_daily_games, 2),
|
|
1532
|
-
'target_date': config.games_target_date,
|
|
1533
|
-
'target': config.games_target,
|
|
1534
|
-
'current': current_games
|
|
1535
|
-
}
|
|
1536
|
-
except ValueError:
|
|
1537
|
-
result['games'] = {'projection': 0, 'daily_average': round(avg_daily_games, 2)}
|
|
1538
|
-
else:
|
|
1539
|
-
result['games'] = {'projection': 0, 'daily_average': round(avg_daily_games, 2)}
|
|
1540
|
-
|
|
1541
|
-
return jsonify(result), 200
|
|
1542
|
-
|
|
1543
|
-
except Exception as e:
|
|
1544
|
-
logger.error(f"Error calculating goal projections: {e}")
|
|
1545
|
-
return jsonify({'error': 'Failed to calculate projections'}), 500
|
|
1546
1527
|
|
|
1547
|
-
|
|
1548
|
-
def api_import_exstatic():
|
|
1549
|
-
"""
|
|
1550
|
-
Import ExStatic CSV data into GSM database.
|
|
1551
|
-
Expected CSV format: uuid,given_identifier,name,line,time
|
|
1552
|
-
"""
|
|
1553
|
-
try:
|
|
1554
|
-
# Check if file is provided
|
|
1555
|
-
if 'file' not in request.files:
|
|
1556
|
-
return jsonify({'error': 'No file provided'}), 400
|
|
1557
|
-
|
|
1558
|
-
file = request.files['file']
|
|
1559
|
-
if file.filename == '':
|
|
1560
|
-
return jsonify({'error': 'No file selected'}), 400
|
|
1561
|
-
|
|
1562
|
-
# Validate file type
|
|
1563
|
-
if not file.filename.lower().endswith('.csv'):
|
|
1564
|
-
return jsonify({'error': 'File must be a CSV file'}), 400
|
|
1565
|
-
|
|
1566
|
-
# Read and parse CSV
|
|
1567
|
-
try:
|
|
1568
|
-
# Read file content as text with proper encoding handling
|
|
1569
|
-
file_content = file.read().decode('utf-8-sig') # Handle BOM if present
|
|
1570
|
-
|
|
1571
|
-
# First, get the header line manually to avoid issues with multi-line content
|
|
1572
|
-
lines = file_content.split('\n')
|
|
1573
|
-
if len(lines) == 1 and not lines[0].strip():
|
|
1574
|
-
return jsonify({'error': 'Empty CSV file'}), 400
|
|
1575
|
-
|
|
1576
|
-
header_line = lines[0].strip()
|
|
1577
|
-
logger.info(f"Header line: {header_line}")
|
|
1578
|
-
|
|
1579
|
-
# Parse headers manually
|
|
1580
|
-
header_reader = csv.reader([header_line])
|
|
1528
|
+
# Trigger stats rollup after successful merge
|
|
1581
1529
|
try:
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
# Validate headers
|
|
1589
|
-
expected_headers = {'uuid', 'given_identifier', 'name', 'line', 'time'}
|
|
1590
|
-
actual_headers = set(headers)
|
|
1591
|
-
|
|
1592
|
-
if not expected_headers.issubset(actual_headers):
|
|
1593
|
-
missing_headers = expected_headers - actual_headers
|
|
1594
|
-
# Check if this looks like a stats CSV instead of lines CSV
|
|
1595
|
-
if 'client' in actual_headers and 'chars_read' in actual_headers:
|
|
1596
|
-
return jsonify({
|
|
1597
|
-
'error': 'This appears to be an ExStatic stats CSV. Please upload the ExStatic lines CSV file instead. The lines CSV should contain columns: uuid, given_identifier, name, line, time'
|
|
1598
|
-
}), 400
|
|
1599
|
-
else:
|
|
1600
|
-
return jsonify({
|
|
1601
|
-
'error': f'Invalid CSV format. Missing required columns: {", ".join(missing_headers)}. Expected format: uuid, given_identifier, name, line, time. Found headers: {", ".join(actual_headers)}'
|
|
1602
|
-
}), 400
|
|
1603
|
-
|
|
1604
|
-
# Now parse the full CSV with proper handling for multi-line fields
|
|
1605
|
-
file_io = io.StringIO(file_content)
|
|
1606
|
-
csv_reader = csv.DictReader(file_io, quoting=csv.QUOTE_MINIMAL, skipinitialspace=True)
|
|
1607
|
-
|
|
1608
|
-
# Process CSV rows
|
|
1609
|
-
games_set = set()
|
|
1610
|
-
errors = []
|
|
1611
|
-
|
|
1612
|
-
all_lines = GameLinesTable.all()
|
|
1613
|
-
existing_uuids = {line.id for line in all_lines}
|
|
1614
|
-
batch_size = 1000 # For logging progress
|
|
1615
|
-
batch_insert = []
|
|
1616
|
-
imported_count = 0
|
|
1617
|
-
|
|
1618
|
-
def get_line_hash(uuid: str, line_text: str) -> str:
|
|
1619
|
-
return uuid + '|' + line_text.strip()
|
|
1620
|
-
|
|
1621
|
-
for row_num, row in enumerate(csv_reader):
|
|
1622
|
-
try:
|
|
1623
|
-
# Extract and validate required fields
|
|
1624
|
-
game_uuid = row.get('uuid', '').strip()
|
|
1625
|
-
game_name = row.get('name', '').strip()
|
|
1626
|
-
line = row.get('line', '').strip()
|
|
1627
|
-
time_str = row.get('time', '').strip()
|
|
1628
|
-
|
|
1629
|
-
# Validate required fields
|
|
1630
|
-
if not game_uuid:
|
|
1631
|
-
errors.append(f"Row {row_num}: Missing UUID")
|
|
1632
|
-
continue
|
|
1633
|
-
if not game_name:
|
|
1634
|
-
errors.append(f"Row {row_num}: Missing name")
|
|
1635
|
-
continue
|
|
1636
|
-
if not line:
|
|
1637
|
-
errors.append(f"Row {row_num}: Missing line text")
|
|
1638
|
-
continue
|
|
1639
|
-
if not time_str:
|
|
1640
|
-
errors.append(f"Row {row_num}: Missing time")
|
|
1641
|
-
continue
|
|
1642
|
-
|
|
1643
|
-
line_hash = get_line_hash(game_uuid, line)
|
|
1644
|
-
|
|
1645
|
-
# Check if this line already exists in database
|
|
1646
|
-
if line_hash in existing_uuids:
|
|
1647
|
-
logger.info(f"Skipping duplicate UUID already in database: {line_hash}")
|
|
1648
|
-
continue
|
|
1530
|
+
logger.info("Triggering stats rollup after game merge")
|
|
1531
|
+
run_daily_rollup()
|
|
1532
|
+
except Exception as rollup_error:
|
|
1533
|
+
logger.error(f"Stats rollup failed after game merge: {rollup_error}")
|
|
1534
|
+
# Don't fail the merge operation if rollup fails
|
|
1649
1535
|
|
|
1650
|
-
# Convert time to timestamp
|
|
1651
|
-
try:
|
|
1652
|
-
timestamp = float(time_str)
|
|
1653
|
-
except ValueError:
|
|
1654
|
-
errors.append(f"Row {row_num}: Invalid time format: {time_str}")
|
|
1655
|
-
continue
|
|
1656
|
-
|
|
1657
|
-
# Clean up line text (remove extra whitespace and newlines)
|
|
1658
|
-
line_text = line.strip()
|
|
1659
|
-
|
|
1660
|
-
# Create GameLinesTable entry
|
|
1661
|
-
# Convert timestamp float to datetime object
|
|
1662
|
-
dt = datetime.datetime.fromtimestamp(timestamp)
|
|
1663
|
-
batch_insert.append(GameLine(
|
|
1664
|
-
id=line_hash,
|
|
1665
|
-
text=line_text,
|
|
1666
|
-
scene=game_name,
|
|
1667
|
-
time=dt,
|
|
1668
|
-
prev=None,
|
|
1669
|
-
next=None,
|
|
1670
|
-
index=0,
|
|
1671
|
-
))
|
|
1672
|
-
|
|
1673
|
-
logger.info(f"Batch insert size: {len(batch_insert)}")
|
|
1674
|
-
|
|
1675
|
-
existing_uuids.add(line_hash) # Add to existing to prevent duplicates in same import
|
|
1676
|
-
|
|
1677
|
-
if len(batch_insert) >= batch_size:
|
|
1678
|
-
logger.info(f"Importing batch of {len(batch_insert)} lines...")
|
|
1679
|
-
GameLinesTable.add_lines(batch_insert)
|
|
1680
|
-
imported_count += len(batch_insert)
|
|
1681
|
-
batch_insert = []
|
|
1682
|
-
games_set.add(game_name)
|
|
1683
|
-
|
|
1684
|
-
except Exception as e:
|
|
1685
|
-
logger.error(f"Error processing row {row_num}: {e}")
|
|
1686
|
-
errors.append(f"Row {row_num}: Error processing row - {str(e)}")
|
|
1687
|
-
continue
|
|
1688
|
-
|
|
1689
|
-
# Insert the rest of the batch
|
|
1690
|
-
if batch_insert:
|
|
1691
|
-
logger.info(f"Importing final batch of {len(batch_insert)} lines...")
|
|
1692
|
-
GameLinesTable.add_lines(batch_insert)
|
|
1693
|
-
imported_count += len(batch_insert)
|
|
1694
|
-
batch_insert = []
|
|
1695
|
-
|
|
1696
|
-
# # Import lines into database
|
|
1697
|
-
# imported_count = 0
|
|
1698
|
-
# for game_line in imported_lines:
|
|
1699
|
-
# try:
|
|
1700
|
-
# game_line.add()
|
|
1701
|
-
# imported_count += 1
|
|
1702
|
-
# except Exception as e:
|
|
1703
|
-
# logger.error(f"Failed to import line {game_line.id}: {e}")
|
|
1704
|
-
# errors.append(f"Failed to import line {game_line.id}: {str(e)}")
|
|
1705
|
-
|
|
1706
|
-
# Prepare response
|
|
1707
|
-
response_data = {
|
|
1708
|
-
'message': f'Successfully imported {imported_count} lines from {len(games_set)} games',
|
|
1709
|
-
'imported_count': imported_count,
|
|
1710
|
-
'games_count': len(games_set),
|
|
1711
|
-
'games': list(games_set)
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
if errors:
|
|
1715
|
-
response_data['warnings'] = errors
|
|
1716
|
-
response_data['warning_count'] = len(errors)
|
|
1717
|
-
|
|
1718
|
-
logger.info(f"ExStatic import completed: {imported_count} lines from {len(games_set)} games")
|
|
1719
|
-
|
|
1720
|
-
logger.info(f"Import response: {response_data}")
|
|
1721
|
-
|
|
1722
1536
|
return jsonify(response_data), 200
|
|
1723
|
-
|
|
1724
|
-
except csv.Error as e:
|
|
1725
|
-
return jsonify({'error': f'CSV parsing error: {str(e)}'}), 400
|
|
1726
|
-
except UnicodeDecodeError:
|
|
1727
|
-
return jsonify({'error': 'File encoding error. Please ensure the CSV is UTF-8 encoded.'}), 400
|
|
1728
|
-
|
|
1729
|
-
except Exception as e:
|
|
1730
|
-
logger.error(f"Error in ExStatic import: {e}")
|
|
1731
|
-
return jsonify({'error': f'Import failed: {str(e)}'}), 500
|
|
1732
|
-
|
|
1733
|
-
@app.route('/api/kanji-sorting-configs', methods=['GET'])
|
|
1734
|
-
def api_kanji_sorting_configs():
|
|
1735
|
-
"""
|
|
1736
|
-
List available kanji sorting configuration JSON files.
|
|
1737
|
-
Returns metadata for each available sorting option.
|
|
1738
|
-
"""
|
|
1739
|
-
try:
|
|
1740
|
-
# Get the kanji_grid directory path
|
|
1741
|
-
template_dir = Path(__file__).parent / 'templates' / 'components' / 'kanji_grid'
|
|
1742
|
-
|
|
1743
|
-
if not template_dir.exists():
|
|
1744
|
-
logger.warning(f"Kanji grid directory does not exist: {template_dir}")
|
|
1745
|
-
return jsonify({'configs': []}), 200
|
|
1746
|
-
|
|
1747
|
-
configs = []
|
|
1748
|
-
|
|
1749
|
-
# Scan for JSON files in the directory
|
|
1750
|
-
for json_file in template_dir.glob('*.json'):
|
|
1751
|
-
try:
|
|
1752
|
-
with open(json_file, 'r', encoding='utf-8') as f:
|
|
1753
|
-
data = json.load(f)
|
|
1754
|
-
|
|
1755
|
-
# Extract metadata from JSON
|
|
1756
|
-
configs.append({
|
|
1757
|
-
'filename': json_file.name,
|
|
1758
|
-
'name': data.get('name', json_file.stem),
|
|
1759
|
-
'version': data.get('version', 1),
|
|
1760
|
-
'lang': data.get('lang', 'ja'),
|
|
1761
|
-
'source': data.get('source', ''),
|
|
1762
|
-
'group_count': len(data.get('groups', []))
|
|
1763
|
-
})
|
|
1764
|
-
except json.JSONDecodeError as e:
|
|
1765
|
-
logger.warning(f"Failed to parse {json_file.name}: {e}")
|
|
1766
|
-
continue
|
|
1767
|
-
except Exception as e:
|
|
1768
|
-
logger.warning(f"Error reading {json_file.name}: {e}")
|
|
1769
|
-
continue
|
|
1770
|
-
|
|
1771
|
-
# Sort by name for consistency
|
|
1772
|
-
configs.sort(key=lambda x: x['name'])
|
|
1773
|
-
|
|
1774
|
-
return jsonify({'configs': configs}), 200
|
|
1775
|
-
|
|
1776
|
-
except Exception as e:
|
|
1777
|
-
logger.error(f"Error fetching kanji sorting configs: {e}")
|
|
1778
|
-
return jsonify({'error': 'Failed to fetch sorting configurations'}), 500
|
|
1779
|
-
|
|
1780
|
-
@app.route('/api/kanji-sorting-config/<filename>', methods=['GET'])
|
|
1781
|
-
def api_kanji_sorting_config(filename):
|
|
1782
|
-
"""
|
|
1783
|
-
Get a specific kanji sorting configuration file.
|
|
1784
|
-
Returns the full JSON configuration.
|
|
1785
|
-
"""
|
|
1786
|
-
try:
|
|
1787
|
-
# Sanitize filename to prevent path traversal
|
|
1788
|
-
if '..' in filename or '/' in filename or '\\' in filename:
|
|
1789
|
-
return jsonify({'error': 'Invalid filename'}), 400
|
|
1790
|
-
|
|
1791
|
-
if not filename.endswith('.json'):
|
|
1792
|
-
filename += '.json'
|
|
1793
|
-
|
|
1794
|
-
# Get the kanji_grid directory path
|
|
1795
|
-
template_dir = Path(__file__).parent / 'templates' / 'components' / 'kanji_grid'
|
|
1796
|
-
config_file = template_dir / filename
|
|
1797
|
-
|
|
1798
|
-
if not config_file.exists() or not config_file.is_file():
|
|
1799
|
-
return jsonify({'error': 'Configuration file not found'}), 404
|
|
1800
|
-
|
|
1801
|
-
# Read and return the JSON configuration
|
|
1802
|
-
with open(config_file, 'r', encoding='utf-8') as f:
|
|
1803
|
-
config_data = json.load(f)
|
|
1804
|
-
|
|
1805
|
-
return jsonify(config_data), 200
|
|
1806
|
-
|
|
1807
|
-
except json.JSONDecodeError as e:
|
|
1808
|
-
logger.error(f"Failed to parse {filename}: {e}")
|
|
1809
|
-
return jsonify({'error': 'Invalid JSON configuration'}), 500
|
|
1810
|
-
except Exception as e:
|
|
1811
|
-
logger.error(f"Error fetching config {filename}: {e}")
|
|
1812
|
-
return jsonify({'error': 'Failed to fetch configuration'}), 500
|
|
1813
1537
|
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
# Check if we have any data
|
|
1823
|
-
count_result = GameLinesTable._db.fetchone("SELECT COUNT(*) FROM game_lines")
|
|
1824
|
-
total_count = count_result[0] if count_result else 0
|
|
1825
|
-
|
|
1826
|
-
# Try to get a sample record
|
|
1827
|
-
sample_record = None
|
|
1828
|
-
if total_count > 0:
|
|
1829
|
-
sample_row = GameLinesTable._db.fetchone("SELECT * FROM game_lines LIMIT 1")
|
|
1830
|
-
if sample_row:
|
|
1831
|
-
sample_record = {
|
|
1832
|
-
'row_length': len(sample_row),
|
|
1833
|
-
'sample_data': sample_row[:5] if len(sample_row) > 5 else sample_row # First 5 columns only
|
|
1538
|
+
except Exception as db_error:
|
|
1539
|
+
logger.error(
|
|
1540
|
+
f"Database error during game merge: {db_error}", exc_info=True
|
|
1541
|
+
)
|
|
1542
|
+
return jsonify(
|
|
1543
|
+
{
|
|
1544
|
+
"error": f"Failed to merge games due to database error: {str(db_error)}"
|
|
1834
1545
|
}
|
|
1835
|
-
|
|
1836
|
-
# Test the model
|
|
1837
|
-
model_info = {
|
|
1838
|
-
'fields_count': len(GameLinesTable._fields),
|
|
1839
|
-
'types_count': len(GameLinesTable._types),
|
|
1840
|
-
'fields': GameLinesTable._fields,
|
|
1841
|
-
'types': [str(t) for t in GameLinesTable._types]
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
return jsonify({
|
|
1845
|
-
'table_structure': table_structure,
|
|
1846
|
-
'total_records': total_count,
|
|
1847
|
-
'sample_record': sample_record,
|
|
1848
|
-
'model_info': model_info
|
|
1849
|
-
}), 200
|
|
1850
|
-
|
|
1851
|
-
except Exception as e:
|
|
1852
|
-
logger.error(f"Error in debug endpoint: {e}")
|
|
1853
|
-
return jsonify({'error': f'Debug failed: {str(e)}'}), 500
|
|
1546
|
+
), 500
|
|
1854
1547
|
|
|
1548
|
+
except Exception as e:
|
|
1549
|
+
logger.error(f"Error in game merge API: {e}")
|
|
1550
|
+
return jsonify({"error": f"Game merge failed: {str(e)}"}), 500
|