GameSentenceMiner 2.17.7__py3-none-any.whl → 2.18.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.
Files changed (51) hide show
  1. GameSentenceMiner/ai/ai_prompting.py +6 -6
  2. GameSentenceMiner/anki.py +236 -152
  3. GameSentenceMiner/gametext.py +7 -4
  4. GameSentenceMiner/gsm.py +49 -10
  5. GameSentenceMiner/locales/en_us.json +7 -3
  6. GameSentenceMiner/locales/ja_jp.json +8 -4
  7. GameSentenceMiner/locales/zh_cn.json +8 -4
  8. GameSentenceMiner/obs.py +238 -59
  9. GameSentenceMiner/ocr/owocr_helper.py +1 -1
  10. GameSentenceMiner/tools/ss_selector.py +7 -8
  11. GameSentenceMiner/ui/__init__.py +0 -0
  12. GameSentenceMiner/ui/anki_confirmation.py +187 -0
  13. GameSentenceMiner/{config_gui.py → ui/config_gui.py} +100 -35
  14. GameSentenceMiner/ui/screenshot_selector.py +215 -0
  15. GameSentenceMiner/util/configuration.py +124 -22
  16. GameSentenceMiner/util/db.py +22 -13
  17. GameSentenceMiner/util/downloader/download_tools.py +2 -2
  18. GameSentenceMiner/util/ffmpeg.py +24 -30
  19. GameSentenceMiner/util/get_overlay_coords.py +34 -34
  20. GameSentenceMiner/util/gsm_utils.py +31 -1
  21. GameSentenceMiner/util/text_log.py +11 -9
  22. GameSentenceMiner/vad.py +31 -12
  23. GameSentenceMiner/web/database_api.py +742 -123
  24. GameSentenceMiner/web/static/css/dashboard-shared.css +241 -0
  25. GameSentenceMiner/web/static/css/kanji-grid.css +94 -2
  26. GameSentenceMiner/web/static/css/overview.css +850 -0
  27. GameSentenceMiner/web/static/css/popups-shared.css +126 -0
  28. GameSentenceMiner/web/static/css/shared.css +97 -0
  29. GameSentenceMiner/web/static/css/stats.css +192 -597
  30. GameSentenceMiner/web/static/js/anki_stats.js +6 -4
  31. GameSentenceMiner/web/static/js/database.js +209 -5
  32. GameSentenceMiner/web/static/js/goals.js +610 -0
  33. GameSentenceMiner/web/static/js/kanji-grid.js +267 -4
  34. GameSentenceMiner/web/static/js/overview.js +1176 -0
  35. GameSentenceMiner/web/static/js/shared.js +25 -0
  36. GameSentenceMiner/web/static/js/stats.js +154 -1459
  37. GameSentenceMiner/web/stats.py +2 -2
  38. GameSentenceMiner/web/templates/anki_stats.html +5 -0
  39. GameSentenceMiner/web/templates/components/navigation.html +3 -1
  40. GameSentenceMiner/web/templates/database.html +73 -1
  41. GameSentenceMiner/web/templates/goals.html +376 -0
  42. GameSentenceMiner/web/templates/index.html +13 -11
  43. GameSentenceMiner/web/templates/overview.html +416 -0
  44. GameSentenceMiner/web/templates/stats.html +46 -251
  45. GameSentenceMiner/web/texthooking_page.py +18 -0
  46. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/METADATA +5 -1
  47. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/RECORD +51 -41
  48. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/WHEEL +0 -0
  49. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/entry_points.txt +0 -0
  50. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/licenses/LICENSE +0 -0
  51. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/top_level.txt +0 -0
@@ -3,8 +3,11 @@ import datetime
3
3
  import re
4
4
  import csv
5
5
  import io
6
+ import os
7
+ import json
6
8
  from collections import defaultdict
7
9
  import time
10
+ from pathlib import Path
8
11
 
9
12
  import flask
10
13
  from flask import request, jsonify
@@ -212,7 +215,7 @@ def register_database_api_routes(app):
212
215
  return jsonify({'games': games_data}), 200
213
216
 
214
217
  except Exception as e:
215
- logger.error(f"Error fetching games list: {e}")
218
+ logger.error(f"Error fetching games list: {e}", exc_info=True)
216
219
  return jsonify({'error': 'Failed to fetch games list'}), 500
217
220
 
218
221
  @app.route('/api/delete-games', methods=['POST'])
@@ -306,6 +309,9 @@ def register_database_api_routes(app):
306
309
  'reading_hours_target': config.reading_hours_target,
307
310
  'character_count_target': config.character_count_target,
308
311
  'games_target': config.games_target,
312
+ 'reading_hours_target_date': config.reading_hours_target_date,
313
+ 'character_count_target_date': config.character_count_target_date,
314
+ 'games_target_date': config.games_target_date,
309
315
  }), 200
310
316
  except Exception as e:
311
317
  logger.error(f"Error getting settings: {e}")
@@ -328,6 +334,9 @@ def register_database_api_routes(app):
328
334
  reading_hours_target = data.get('reading_hours_target')
329
335
  character_count_target = data.get('character_count_target')
330
336
  games_target = data.get('games_target')
337
+ reading_hours_target_date = data.get('reading_hours_target_date')
338
+ character_count_target_date = data.get('character_count_target_date')
339
+ games_target_date = data.get('games_target_date')
331
340
 
332
341
  # Validate input - only require the settings that are provided
333
342
  settings_to_update = {}
@@ -386,6 +395,37 @@ def register_database_api_routes(app):
386
395
  except (ValueError, TypeError):
387
396
  return jsonify({'error': 'Games target must be a valid integer'}), 400
388
397
 
398
+ # Validate target dates (ISO format: YYYY-MM-DD)
399
+ if reading_hours_target_date is not None:
400
+ if reading_hours_target_date == '':
401
+ settings_to_update['reading_hours_target_date'] = ''
402
+ else:
403
+ try:
404
+ datetime.datetime.strptime(reading_hours_target_date, '%Y-%m-%d')
405
+ settings_to_update['reading_hours_target_date'] = reading_hours_target_date
406
+ except ValueError:
407
+ return jsonify({'error': 'Reading hours target date must be in YYYY-MM-DD format'}), 400
408
+
409
+ if character_count_target_date is not None:
410
+ if character_count_target_date == '':
411
+ settings_to_update['character_count_target_date'] = ''
412
+ else:
413
+ try:
414
+ datetime.datetime.strptime(character_count_target_date, '%Y-%m-%d')
415
+ settings_to_update['character_count_target_date'] = character_count_target_date
416
+ except ValueError:
417
+ return jsonify({'error': 'Character count target date must be in YYYY-MM-DD format'}), 400
418
+
419
+ if games_target_date is not None:
420
+ if games_target_date == '':
421
+ settings_to_update['games_target_date'] = ''
422
+ else:
423
+ try:
424
+ datetime.datetime.strptime(games_target_date, '%Y-%m-%d')
425
+ settings_to_update['games_target_date'] = games_target_date
426
+ except ValueError:
427
+ return jsonify({'error': 'Games target date must be in YYYY-MM-DD format'}), 400
428
+
389
429
  if not settings_to_update:
390
430
  return jsonify({'error': 'No valid settings provided'}), 400
391
431
 
@@ -404,6 +444,12 @@ def register_database_api_routes(app):
404
444
  config.character_count_target = settings_to_update['character_count_target']
405
445
  if 'games_target' in settings_to_update:
406
446
  config.games_target = settings_to_update['games_target']
447
+ if 'reading_hours_target_date' in settings_to_update:
448
+ config.reading_hours_target_date = settings_to_update['reading_hours_target_date']
449
+ if 'character_count_target_date' in settings_to_update:
450
+ config.character_count_target_date = settings_to_update['character_count_target_date']
451
+ if 'games_target_date' in settings_to_update:
452
+ config.games_target_date = settings_to_update['games_target_date']
407
453
 
408
454
  save_stats_config(config)
409
455
 
@@ -786,146 +832,596 @@ def register_database_api_routes(app):
786
832
  logger.error(f"Error in deduplication: {e}")
787
833
  return jsonify({'error': f'Deduplication failed: {str(e)}'}), 500
788
834
 
835
+ @app.route('/api/merge_games', methods=['POST'])
836
+ def api_merge_games():
837
+ """
838
+ Merges multiple selected games into a single game entry.
839
+ The first game in the list becomes the primary game that retains its name.
840
+ All lines from secondary games are moved to the primary game.
841
+ """
842
+ try:
843
+ data = request.get_json()
844
+ target_game = data.get('target_game', None)
845
+ games_to_merge = data.get('games_to_merge', [])
846
+
847
+ logger.info(f"Merge request received: target_game='{target_game}', games_to_merge={games_to_merge}")
848
+
849
+ # Validation
850
+ if not target_game:
851
+ return jsonify({'error': 'No target game specified for merging'}), 400
852
+
853
+ if not games_to_merge:
854
+ return jsonify({'error': 'No games specified for merging'}), 400
855
+
856
+ if not isinstance(games_to_merge, list):
857
+ return jsonify({'error': 'game_names must be a list'}), 400
858
+
859
+ if len(games_to_merge) < 1:
860
+ return jsonify({'error': 'At least 1 game must be selected for merging'}), 400
861
+
862
+ # Validate that all games exist
863
+ existing_games = GameLinesTable.get_all_games_with_lines()
864
+ invalid_games = [name for name in games_to_merge if name not in existing_games]
865
+
866
+ if invalid_games:
867
+ return jsonify({'error': f'Games not found: {", ".join(invalid_games)}'}), 400
868
+
869
+ # Check for duplicate game names
870
+ if len(set(games_to_merge)) != len(games_to_merge):
871
+ return jsonify({'error': 'Duplicate game names found in selection'}), 400
872
+
873
+ # Identify primary and secondary games
874
+
875
+ # Collect pre-merge statistics
876
+ primary_lines_before = GameLinesTable.get_all_lines_for_scene(target_game)
877
+ total_lines_to_merge = 0
878
+ merge_summary = {
879
+ 'primary_game': target_game,
880
+ 'secondary_games': games_to_merge,
881
+ 'lines_moved': 0,
882
+ 'total_lines_after_merge': 0
883
+ }
884
+
885
+ # Calculate lines to be moved and store counts
886
+ secondary_game_line_counts = {}
887
+ for game_name in games_to_merge:
888
+ secondary_lines = GameLinesTable.get_all_lines_for_scene(game_name)
889
+ line_count = len(secondary_lines)
890
+ secondary_game_line_counts[game_name] = line_count
891
+ total_lines_to_merge += line_count
892
+
893
+ if total_lines_to_merge == 0:
894
+ return jsonify({'error': 'No lines found in secondary games to merge'}), 400
895
+
896
+ # Begin database transaction for merge
897
+ try:
898
+ # Perform the merge operation within transaction
899
+ lines_moved = 0
900
+ for game_name in games_to_merge:
901
+ # Update game_name for all lines belonging to this secondary game
902
+ # Also set original_game_name to preserve the original title
903
+ # Ensure the table name is as expected to prevent SQL injection
904
+ if GameLinesTable._table != "game_lines":
905
+ raise ValueError("Unexpected table name in GameLinesTable._table")
906
+ GameLinesTable._db.execute(
907
+ "UPDATE game_lines SET game_name=?, original_game_name=COALESCE(original_game_name, ?) WHERE game_name=?",
908
+ (target_game, game_name, game_name),
909
+ commit=True
910
+ )
911
+
912
+ # Add the count we calculated earlier
913
+ lines_moved += secondary_game_line_counts[game_name]
914
+
915
+ # Update merge summary
916
+ merge_summary['lines_moved'] = lines_moved
917
+ merge_summary['total_lines_after_merge'] = len(primary_lines_before) + lines_moved
918
+
919
+ # Log the successful merge
920
+ logger.info(f"Successfully merged {len(games_to_merge)} games into '{target_game}': moved {lines_moved} lines")
921
+
922
+ # Prepare success response
923
+ response_data = {
924
+ 'message': f'Successfully merged {len(games_to_merge)} games into "{target_game}"',
925
+ 'primary_game': target_game,
926
+ 'merged_games': games_to_merge,
927
+ 'lines_moved': lines_moved,
928
+ 'total_lines_in_primary': merge_summary['total_lines_after_merge'],
929
+ 'merge_summary': merge_summary
930
+ }
931
+
932
+ return jsonify(response_data), 200
933
+
934
+ except Exception as db_error:
935
+ logger.error(f"Database error during game merge: {db_error}", exc_info=True)
936
+ return jsonify({
937
+ 'error': f'Failed to merge games due to database error: {str(db_error)}'
938
+ }), 500
939
+
940
+ except Exception as e:
941
+ logger.error(f"Error in game merge API: {e}")
942
+ return jsonify({'error': f'Game merge failed: {str(e)}'}), 500
943
+
789
944
  @app.route('/api/stats')
790
945
  def api_stats():
791
946
  """
792
947
  Provides aggregated, cumulative stats for charting.
793
948
  Accepts optional 'year' parameter to filter heatmap data.
794
949
  """
795
- punctionation_regex = regex.compile(r'[\p{P}\p{S}\p{Z}]')
796
- # Get optional year filter parameter
797
- filter_year = request.args.get('year', None)
950
+ try:
951
+ punctionation_regex = regex.compile(r'[\p{P}\p{S}\p{Z}]')
952
+ # Get optional year filter parameter
953
+ filter_year = request.args.get('year', None)
798
954
 
799
- # Get Start and End time as unix timestamp
800
- start_timestamp = request.args.get('start', None)
801
- end_timestamp = request.args.get('end', None)
802
-
803
- # Convert timestamps to float if provided
804
- start_timestamp = float(start_timestamp) if start_timestamp else None
805
- end_timestamp = float(end_timestamp) if end_timestamp else None
955
+ # Get Start and End time as unix timestamp
956
+ start_timestamp = request.args.get('start', None)
957
+ end_timestamp = request.args.get('end', None)
958
+
959
+ # Convert timestamps to float if provided
960
+ start_timestamp = float(start_timestamp) if start_timestamp else None
961
+ end_timestamp = float(end_timestamp) if end_timestamp else None
806
962
 
807
- # # 1. Fetch all lines and sort them chronologically
808
- # all_lines = sorted(GameLinesTable.all(), key=lambda line: line.timestamp)
963
+ # 1. Fetch all lines and sort them chronologically
964
+ all_lines = GameLinesTable.get_lines_filtered_by_timestamp(start=start_timestamp, end=end_timestamp)
965
+
966
+ if not all_lines:
967
+ return jsonify({"labels": [], "datasets": []})
968
+
969
+ # 2. Process data into daily totals for each game
970
+ # Structure: daily_data[date_str][game_name] = {'lines': N, 'chars': N}
971
+ daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
972
+ wrong_instance_found = False
973
+ for line in all_lines:
974
+ day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
975
+ game = line.game_name or "Unknown Game"
976
+ # Remove punctuation and symbols from line text before counting characters
977
+ clean_text = punctionation_regex.sub('', str(line.line_text)) if line.line_text else ''
978
+ if not isinstance(clean_text, str) and not wrong_instance_found:
979
+ logger.info(f"Non-string line_text encountered: {clean_text} (type: {type(clean_text)})")
980
+ wrong_instance_found = True
981
+
982
+ line.line_text = clean_text # Update line text to cleaned version for future use
983
+ daily_data[day_str][game]['lines'] += 1
984
+ daily_data[day_str][game]['chars'] += len(clean_text)
985
+
986
+ # 3. Create cumulative datasets for Chart.js
987
+ sorted_days = sorted(daily_data.keys())
988
+ game_names = GameLinesTable.get_all_games_with_lines()
989
+
990
+ # Keep track of the running total for each metric for each game
991
+ cumulative_totals = defaultdict(lambda: {'lines': 0, 'chars': 0})
992
+
993
+ # Structure for final data: final_data[game_name][metric] = [day1_val, day2_val, ...]
994
+ final_data = defaultdict(lambda: defaultdict(list))
809
995
 
810
- # 1. Fetch all lines and sort them chronologically
811
- all_lines = GameLinesTable.get_lines_filtered_by_timestamp(start=start_timestamp, end=end_timestamp)
812
-
813
- if not all_lines:
814
- return jsonify({"labels": [], "datasets": []})
996
+ for day in sorted_days:
997
+ for game in game_names:
998
+ # Add the day's total to the cumulative total
999
+ cumulative_totals[game]['lines'] += daily_data[day][game]['lines']
1000
+ cumulative_totals[game]['chars'] += daily_data[day][game]['chars']
1001
+
1002
+ # Append the new cumulative total to the list for that day
1003
+ final_data[game]['lines'].append(cumulative_totals[game]['lines'])
1004
+ final_data[game]['chars'].append(cumulative_totals[game]['chars'])
1005
+
1006
+ # 4. Format into Chart.js dataset structure
1007
+ datasets = []
1008
+ # A simple color palette for the chart lines
1009
+ colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
1010
+
1011
+ for i, game in enumerate(game_names):
1012
+ color = colors[i % len(colors)]
1013
+
1014
+ # 1. Fetch all lines and sort them chronologically
1015
+ try:
1016
+ all_lines = sorted(GameLinesTable.all(), key=lambda line: line.timestamp)
1017
+ except Exception as e:
1018
+ logger.error(f"Error fetching lines from database: {e}")
1019
+ return jsonify({'error': 'Failed to fetch data from database'}), 500
1020
+
1021
+ if not all_lines:
1022
+ return jsonify({"labels": [], "datasets": []})
815
1023
 
816
- # 2. Process data into daily totals for each game
817
- # Structure: daily_data[date_str][game_name] = {'lines': N, 'chars': N}
818
- daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
819
-
820
-
821
-
822
-
823
- # start_time = time.perf_counter()
824
- # for line in all_lines:
825
- # day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
826
- # game = line.game_name or "Unknown Game"
827
- # daily_data[day_str][game]['lines'] += 1
828
- # daily_data[day_str][game]['chars'] += len(line.line_text) if line.line_text else 0
829
- # end_time = time.perf_counter()
830
- # logger.info(f"Without Punctuation removal and daily aggregation took {end_time - start_time:.4f} seconds for {len(all_lines)} lines")
1024
+ # 2. Process data into daily totals for each game
1025
+ # Structure: daily_data[date_str][game_name] = {'lines': N, 'chars': N}
1026
+ daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
831
1027
 
832
- # start_time = time.perf_counter()
833
- wrong_instance_found = False
834
- for line in all_lines:
835
- day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
836
- game = line.game_name or "Unknown Game"
837
- # Remove punctuation and symbols from line text before counting characters
838
- clean_text = punctionation_regex.sub('', str(line.line_text)) if line.line_text else ''
839
- if not isinstance(clean_text, str) and not wrong_instance_found:
840
- logger.info(f"Non-string line_text encountered: {clean_text} (type: {type(clean_text)})")
841
- wrong_instance_found = True
1028
+ try:
1029
+ # start_time = time.perf_counter()
1030
+ # for line in all_lines:
1031
+ # day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
1032
+ # game = line.game_name or "Unknown Game"
1033
+ # daily_data[day_str][game]['lines'] += 1
1034
+ # daily_data[day_str][game]['chars'] += len(line.line_text) if line.line_text else 0
1035
+ # end_time = time.perf_counter()
1036
+ # logger.info(f"Without Punctuation removal and daily aggregation took {end_time - start_time:.4f} seconds for {len(all_lines)} lines")
842
1037
 
843
- line.line_text = clean_text # Update line text to cleaned version for future use
844
- daily_data[day_str][game]['lines'] += 1
845
- daily_data[day_str][game]['chars'] += len(clean_text)
846
- # end_time = time.perf_counter()
847
- # logger.info(f"With Punctuation removal and daily aggregation took {end_time - start_time:.4f} seconds for {len(all_lines)} lines")
1038
+ # start_time = time.perf_counter()
1039
+ wrong_instance_found = False
1040
+ for line in all_lines:
1041
+ day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
1042
+ game = line.game_name or "Unknown Game"
1043
+ # Remove punctuation and symbols from line text before counting characters
1044
+ clean_text = punctionation_regex.sub('', str(line.line_text)) if line.line_text else ''
1045
+ if not isinstance(clean_text, str) and not wrong_instance_found:
1046
+ logger.info(f"Non-string line_text encountered: {clean_text} (type: {type(clean_text)})")
1047
+ wrong_instance_found = True
848
1048
 
849
- # 3. Create cumulative datasets for Chart.js
850
- sorted_days = sorted(daily_data.keys())
851
- game_names = GameLinesTable.get_all_games_with_lines()
852
-
853
- # Keep track of the running total for each metric for each game
854
- cumulative_totals = defaultdict(lambda: {'lines': 0, 'chars': 0})
855
-
856
- # Structure for final data: final_data[game_name][metric] = [day1_val, day2_val, ...]
857
- final_data = defaultdict(lambda: defaultdict(list))
1049
+ line.line_text = clean_text # Update line text to cleaned version for future use
1050
+ daily_data[day_str][game]['lines'] += 1
1051
+ daily_data[day_str][game]['chars'] += len(clean_text)
1052
+ # end_time = time.perf_counter()
1053
+ # logger.info(f"With Punctuation removal and daily aggregation took {end_time - start_time:.4f} seconds for {len(all_lines)} lines")
1054
+ except Exception as e:
1055
+ logger.error(f"Error processing daily data: {e}")
1056
+ return jsonify({'error': 'Failed to process daily data'}), 500
858
1057
 
859
- for day in sorted_days:
860
- for game in game_names:
861
- # Add the day's total to the cumulative total
862
- cumulative_totals[game]['lines'] += daily_data[day][game]['lines']
863
- cumulative_totals[game]['chars'] += daily_data[day][game]['chars']
1058
+ # 3. Create cumulative datasets for Chart.js
1059
+ try:
1060
+ sorted_days = sorted(daily_data.keys())
1061
+ game_names = GameLinesTable.get_all_games_with_lines()
864
1062
 
865
- # Append the new cumulative total to the list for that day
866
- final_data[game]['lines'].append(cumulative_totals[game]['lines'])
867
- final_data[game]['chars'].append(cumulative_totals[game]['chars'])
868
-
869
- # 4. Format into Chart.js dataset structure
870
- datasets = []
871
- # A simple color palette for the chart lines
872
- colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
873
-
874
- for i, game in enumerate(game_names):
875
- color = colors[i % len(colors)]
876
-
877
- datasets.append({
878
- "label": f"{game}",
879
- "for": "Lines Received",
880
- "data": final_data[game]['lines'],
881
- "borderColor": color,
882
- "backgroundColor": f"{color}33", # Semi-transparent for fill
883
- "fill": False,
884
- "tension": 0.1
885
- })
886
- datasets.append({
887
- "label": f"{game}",
888
- "for": "Characters Read",
889
- "data": final_data[game]['chars'],
890
- "borderColor": color,
891
- "backgroundColor": f"{color}33",
892
- "fill": False,
893
- "tension": 0.1,
894
- "hidden": True # Hide by default to not clutter the chart
895
- })
1063
+ # Keep track of the running total for each metric for each game
1064
+ cumulative_totals = defaultdict(lambda: {'lines': 0, 'chars': 0})
1065
+
1066
+ # Structure for final data: final_data[game_name][metric] = [day1_val, day2_val, ...]
1067
+ final_data = defaultdict(lambda: defaultdict(list))
1068
+
1069
+ for day in sorted_days:
1070
+ for game in game_names:
1071
+ # Add the day's total to the cumulative total
1072
+ cumulative_totals[game]['lines'] += daily_data[day][game]['lines']
1073
+ cumulative_totals[game]['chars'] += daily_data[day][game]['chars']
1074
+
1075
+ # Append the new cumulative total to the list for that day
1076
+ final_data[game]['lines'].append(cumulative_totals[game]['lines'])
1077
+ final_data[game]['chars'].append(cumulative_totals[game]['chars'])
1078
+ except Exception as e:
1079
+ logger.error(f"Error creating cumulative datasets: {e}")
1080
+ return jsonify({'error': 'Failed to create datasets'}), 500
1081
+
1082
+ # 4. Format into Chart.js dataset structure
1083
+ try:
1084
+ datasets = []
1085
+ # A simple color palette for the chart lines
1086
+ colors = ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22']
1087
+
1088
+ for i, game in enumerate(game_names):
1089
+ color = colors[i % len(colors)]
1090
+
1091
+ datasets.append({
1092
+ "label": f"{game} - Lines Received",
1093
+ "data": final_data[game]['lines'],
1094
+ "borderColor": color,
1095
+ "backgroundColor": f"{color}33", # Semi-transparent for fill
1096
+ "fill": False,
1097
+ "tension": 0.1,
1098
+ "for": "Lines Received"
1099
+ })
1100
+ datasets.append({
1101
+ "label": f"{game} - Characters Read",
1102
+ "data": final_data[game]['chars'],
1103
+ "borderColor": color,
1104
+ "backgroundColor": f"{color}33",
1105
+ "fill": False,
1106
+ "tension": 0.1,
1107
+ "hidden": True, # Hide by default to not clutter the chart
1108
+ "for": "Characters Read"
1109
+ })
1110
+ except Exception as e:
1111
+ logger.error(f"Error formatting Chart.js datasets: {e}")
1112
+ return jsonify({'error': 'Failed to format chart data'}), 500
1113
+
1114
+ # 5. Calculate additional chart data
1115
+ try:
1116
+ kanji_grid_data = calculate_kanji_frequency(all_lines)
1117
+ except Exception as e:
1118
+ logger.error(f"Error calculating kanji frequency: {e}")
1119
+ kanji_grid_data = []
1120
+
1121
+ try:
1122
+ heatmap_data = calculate_heatmap_data(all_lines, filter_year)
1123
+ except Exception as e:
1124
+ logger.error(f"Error calculating heatmap data: {e}")
1125
+ heatmap_data = []
1126
+
1127
+ try:
1128
+ total_chars_data = calculate_total_chars_per_game(all_lines)
1129
+ except Exception as e:
1130
+ logger.error(f"Error calculating total chars per game: {e}")
1131
+ total_chars_data = {}
1132
+
1133
+ try:
1134
+ reading_time_data = calculate_reading_time_per_game(all_lines)
1135
+ except Exception as e:
1136
+ logger.error(f"Error calculating reading time per game: {e}")
1137
+ reading_time_data = {}
1138
+
1139
+ try:
1140
+ reading_speed_per_game_data = calculate_reading_speed_per_game(all_lines)
1141
+ except Exception as e:
1142
+ logger.error(f"Error calculating reading speed per game: {e}")
1143
+ reading_speed_per_game_data = {}
1144
+
1145
+ # 6. Calculate dashboard statistics
1146
+ try:
1147
+ current_game_stats = calculate_current_game_stats(all_lines)
1148
+ except Exception as e:
1149
+ logger.error(f"Error calculating current game stats: {e}")
1150
+ current_game_stats = {}
1151
+
1152
+ try:
1153
+ all_games_stats = calculate_all_games_stats(all_lines)
1154
+ except Exception as e:
1155
+ logger.error(f"Error calculating all games stats: {e}")
1156
+ all_games_stats = {}
896
1157
 
897
- # 5. Calculate additional chart data
898
- kanji_grid_data = calculate_kanji_frequency(all_lines)
899
- heatmap_data = calculate_heatmap_data(all_lines, filter_year)
900
- total_chars_data = calculate_total_chars_per_game(all_lines)
901
- reading_time_data = calculate_reading_time_per_game(all_lines)
902
- reading_speed_per_game_data = calculate_reading_speed_per_game(all_lines)
903
-
904
- # 6. Calculate dashboard statistics
905
- current_game_stats = calculate_current_game_stats(all_lines)
906
- all_games_stats = calculate_all_games_stats(all_lines)
1158
+ # 7. Prepare allLinesData for frontend calculations (needed for average daily time)
1159
+ try:
1160
+ all_lines_data = []
1161
+ for line in all_lines:
1162
+ all_lines_data.append({
1163
+ 'timestamp': float(line.timestamp),
1164
+ 'game_name': line.game_name or 'Unknown Game',
1165
+ 'characters': len(line.line_text) if line.line_text else 0
1166
+ })
1167
+ except Exception as e:
1168
+ logger.error(f"Error preparing all lines data: {e}")
1169
+ all_lines_data = []
907
1170
 
908
- # 7. Prepare allLinesData for frontend calculations (needed for average daily time)
909
- all_lines_data = []
910
- for line in all_lines:
911
- all_lines_data.append({
912
- 'timestamp': float(line.timestamp),
913
- 'game_name': line.game_name or 'Unknown Game',
914
- 'characters': len(line.line_text) if line.line_text else 0
1171
+ return jsonify({
1172
+ "labels": sorted_days,
1173
+ "datasets": datasets,
1174
+ "kanjiGridData": kanji_grid_data,
1175
+ "heatmapData": heatmap_data,
1176
+ "totalCharsPerGame": total_chars_data,
1177
+ "readingTimePerGame": reading_time_data,
1178
+ "readingSpeedPerGame": reading_speed_per_game_data,
1179
+ "currentGameStats": current_game_stats,
1180
+ "allGamesStats": all_games_stats,
1181
+ "allLinesData": all_lines_data
915
1182
  })
1183
+
1184
+ except Exception as e:
1185
+ logger.error(f"Unexpected error in api_stats: {e}")
1186
+ return jsonify({'error': 'Failed to generate statistics'}), 500
1187
+
1188
+ @app.route('/api/goals-today', methods=['GET'])
1189
+ def api_goals_today():
1190
+ """
1191
+ Calculate daily requirements and current progress for today based on goal target dates.
1192
+ Returns what needs to be accomplished today to stay on track.
1193
+ """
1194
+ try:
1195
+ config = get_stats_config()
1196
+ today = datetime.date.today()
1197
+
1198
+ # Get all lines for overall progress
1199
+ all_lines = GameLinesTable.all()
1200
+ if not all_lines:
1201
+ return jsonify({
1202
+ 'hours': {'required': 0, 'progress': 0, 'has_target': False},
1203
+ 'characters': {'required': 0, 'progress': 0, 'has_target': False},
1204
+ 'games': {'required': 0, 'progress': 0, 'has_target': False}
1205
+ }), 200
1206
+
1207
+ # Calculate overall current progress
1208
+ timestamps = [float(line.timestamp) for line in all_lines]
1209
+ total_time_seconds = calculate_actual_reading_time(timestamps)
1210
+ total_hours = total_time_seconds / 3600
1211
+ total_characters = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
1212
+ total_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
1213
+
1214
+ # Get today's lines for progress
1215
+ today_lines = [line for line in all_lines
1216
+ if datetime.date.fromtimestamp(float(line.timestamp)) == today]
1217
+
1218
+ today_timestamps = [float(line.timestamp) for line in today_lines]
1219
+ today_time_seconds = calculate_actual_reading_time(today_timestamps) if len(today_timestamps) >= 2 else 0
1220
+ today_hours = today_time_seconds / 3600
1221
+ today_characters = sum(len(line.line_text) if line.line_text else 0 for line in today_lines)
1222
+
1223
+ result = {}
1224
+
1225
+ # Calculate hours requirement
1226
+ if config.reading_hours_target_date:
1227
+ try:
1228
+ target_date = datetime.datetime.strptime(config.reading_hours_target_date, '%Y-%m-%d').date()
1229
+ days_remaining = (target_date - today).days + 1 # +1 to include today
1230
+ if days_remaining > 0:
1231
+ hours_needed = max(0, config.reading_hours_target - total_hours)
1232
+ daily_hours_required = hours_needed / days_remaining
1233
+ result['hours'] = {
1234
+ 'required': round(daily_hours_required, 2),
1235
+ 'progress': round(today_hours, 2),
1236
+ 'has_target': True,
1237
+ 'target_date': config.reading_hours_target_date,
1238
+ 'days_remaining': days_remaining
1239
+ }
1240
+ else:
1241
+ result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': True, 'expired': True}
1242
+ except ValueError:
1243
+ result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': False}
1244
+ else:
1245
+ result['hours'] = {'required': 0, 'progress': round(today_hours, 2), 'has_target': False}
1246
+
1247
+ # Calculate characters requirement
1248
+ if config.character_count_target_date:
1249
+ try:
1250
+ target_date = datetime.datetime.strptime(config.character_count_target_date, '%Y-%m-%d').date()
1251
+ days_remaining = (target_date - today).days + 1
1252
+ if days_remaining > 0:
1253
+ chars_needed = max(0, config.character_count_target - total_characters)
1254
+ daily_chars_required = int(chars_needed / days_remaining)
1255
+ result['characters'] = {
1256
+ 'required': daily_chars_required,
1257
+ 'progress': today_characters,
1258
+ 'has_target': True,
1259
+ 'target_date': config.character_count_target_date,
1260
+ 'days_remaining': days_remaining
1261
+ }
1262
+ else:
1263
+ result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': True, 'expired': True}
1264
+ except ValueError:
1265
+ result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': False}
1266
+ else:
1267
+ result['characters'] = {'required': 0, 'progress': today_characters, 'has_target': False}
1268
+
1269
+ # Calculate games requirement
1270
+ if config.games_target_date:
1271
+ try:
1272
+ target_date = datetime.datetime.strptime(config.games_target_date, '%Y-%m-%d').date()
1273
+ days_remaining = (target_date - today).days + 1
1274
+ if days_remaining > 0:
1275
+ games_needed = max(0, config.games_target - total_games)
1276
+ daily_games_required = games_needed / days_remaining
1277
+ result['games'] = {
1278
+ 'required': round(daily_games_required, 2),
1279
+ 'progress': total_games,
1280
+ 'has_target': True,
1281
+ 'target_date': config.games_target_date,
1282
+ 'days_remaining': days_remaining
1283
+ }
1284
+ else:
1285
+ result['games'] = {'required': 0, 'progress': total_games, 'has_target': True, 'expired': True}
1286
+ except ValueError:
1287
+ result['games'] = {'required': 0, 'progress': total_games, 'has_target': False}
1288
+ else:
1289
+ result['games'] = {'required': 0, 'progress': total_games, 'has_target': False}
1290
+
1291
+ return jsonify(result), 200
1292
+
1293
+ except Exception as e:
1294
+ logger.error(f"Error calculating goals today: {e}")
1295
+ return jsonify({'error': 'Failed to calculate daily goals'}), 500
916
1296
 
917
- return jsonify({
918
- "labels": sorted_days,
919
- "datasets": datasets,
920
- "kanjiGridData": kanji_grid_data,
921
- "heatmapData": heatmap_data,
922
- "totalCharsPerGame": total_chars_data,
923
- "readingTimePerGame": reading_time_data,
924
- "readingSpeedPerGame": reading_speed_per_game_data,
925
- "currentGameStats": current_game_stats,
926
- "allGamesStats": all_games_stats,
927
- "allLinesData": all_lines_data
928
- })
1297
+ @app.route('/api/goals-projection', methods=['GET'])
1298
+ def api_goals_projection():
1299
+ """
1300
+ Calculate projections based on 30-day rolling average.
1301
+ Returns projected stats by target dates.
1302
+ """
1303
+ try:
1304
+ config = get_stats_config()
1305
+ today = datetime.date.today()
1306
+ thirty_days_ago = today - datetime.timedelta(days=30)
1307
+
1308
+ # Get all lines
1309
+ all_lines = GameLinesTable.all()
1310
+ if not all_lines:
1311
+ return jsonify({
1312
+ 'hours': {'projection': 0, 'daily_average': 0},
1313
+ 'characters': {'projection': 0, 'daily_average': 0},
1314
+ 'games': {'projection': 0, 'daily_average': 0}
1315
+ }), 200
1316
+
1317
+ # Get last 30 days of lines
1318
+ recent_lines = [line for line in all_lines
1319
+ if datetime.date.fromtimestamp(float(line.timestamp)) >= thirty_days_ago]
1320
+
1321
+ # Calculate 30-day averages
1322
+ if recent_lines:
1323
+ # Group by day for accurate averaging
1324
+ daily_data = defaultdict(lambda: {'timestamps': [], 'characters': 0, 'games': set()})
1325
+ for line in recent_lines:
1326
+ day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
1327
+ daily_data[day_str]['timestamps'].append(float(line.timestamp))
1328
+ daily_data[day_str]['characters'] += len(line.line_text) if line.line_text else 0
1329
+ daily_data[day_str]['games'].add(line.game_name or "Unknown Game")
1330
+
1331
+ # Calculate daily averages
1332
+ total_hours = 0
1333
+ total_chars = 0
1334
+ total_unique_games = set()
1335
+
1336
+ for day_data in daily_data.values():
1337
+ if len(day_data['timestamps']) >= 2:
1338
+ day_seconds = calculate_actual_reading_time(day_data['timestamps'])
1339
+ total_hours += day_seconds / 3600
1340
+ total_chars += day_data['characters']
1341
+ total_unique_games.update(day_data['games'])
1342
+
1343
+ # Average over ALL 30 days (including days with 0 activity)
1344
+ avg_daily_hours = total_hours / 30
1345
+ avg_daily_chars = total_chars / 30
1346
+ # Calculate average daily unique games correctly
1347
+ today = datetime.date.today()
1348
+ daily_unique_games_counts = []
1349
+ for i in range(30):
1350
+ day = (today - datetime.timedelta(days=i)).strftime('%Y-%m-%d')
1351
+ daily_unique_games_counts.append(len(daily_data[day]['games']) if day in daily_data else 0)
1352
+ avg_daily_games = sum(daily_unique_games_counts) / 30
1353
+ else:
1354
+ avg_daily_hours = 0
1355
+ avg_daily_chars = 0
1356
+ avg_daily_games = 0
1357
+
1358
+ # Calculate current totals
1359
+ timestamps = [float(line.timestamp) for line in all_lines]
1360
+ current_hours = calculate_actual_reading_time(timestamps) / 3600
1361
+ current_chars = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
1362
+ current_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
1363
+
1364
+ result = {}
1365
+
1366
+ # Project hours by target date
1367
+ if config.reading_hours_target_date:
1368
+ try:
1369
+ target_date = datetime.datetime.strptime(config.reading_hours_target_date, '%Y-%m-%d').date()
1370
+ days_until_target = (target_date - today).days
1371
+ projected_hours = current_hours + (avg_daily_hours * days_until_target)
1372
+ result['hours'] = {
1373
+ 'projection': round(projected_hours, 2),
1374
+ 'daily_average': round(avg_daily_hours, 2),
1375
+ 'target_date': config.reading_hours_target_date,
1376
+ 'target': config.reading_hours_target,
1377
+ 'current': round(current_hours, 2)
1378
+ }
1379
+ except ValueError:
1380
+ result['hours'] = {'projection': 0, 'daily_average': round(avg_daily_hours, 2)}
1381
+ else:
1382
+ result['hours'] = {'projection': 0, 'daily_average': round(avg_daily_hours, 2)}
1383
+
1384
+ # Project characters by target date
1385
+ if config.character_count_target_date:
1386
+ try:
1387
+ target_date = datetime.datetime.strptime(config.character_count_target_date, '%Y-%m-%d').date()
1388
+ days_until_target = (target_date - today).days
1389
+ projected_chars = int(current_chars + (avg_daily_chars * days_until_target))
1390
+ result['characters'] = {
1391
+ 'projection': projected_chars,
1392
+ 'daily_average': int(avg_daily_chars),
1393
+ 'target_date': config.character_count_target_date,
1394
+ 'target': config.character_count_target,
1395
+ 'current': current_chars
1396
+ }
1397
+ except ValueError:
1398
+ result['characters'] = {'projection': 0, 'daily_average': int(avg_daily_chars)}
1399
+ else:
1400
+ result['characters'] = {'projection': 0, 'daily_average': int(avg_daily_chars)}
1401
+
1402
+ # Project games by target date
1403
+ if config.games_target_date:
1404
+ try:
1405
+ target_date = datetime.datetime.strptime(config.games_target_date, '%Y-%m-%d').date()
1406
+ days_until_target = (target_date - today).days
1407
+ projected_games = int(current_games + (avg_daily_games * days_until_target))
1408
+ result['games'] = {
1409
+ 'projection': projected_games,
1410
+ 'daily_average': round(avg_daily_games, 2),
1411
+ 'target_date': config.games_target_date,
1412
+ 'target': config.games_target,
1413
+ 'current': current_games
1414
+ }
1415
+ except ValueError:
1416
+ result['games'] = {'projection': 0, 'daily_average': round(avg_daily_games, 2)}
1417
+ else:
1418
+ result['games'] = {'projection': 0, 'daily_average': round(avg_daily_games, 2)}
1419
+
1420
+ return jsonify(result), 200
1421
+
1422
+ except Exception as e:
1423
+ logger.error(f"Error calculating goal projections: {e}")
1424
+ return jsonify({'error': 'Failed to calculate projections'}), 500
929
1425
 
930
1426
  @app.route('/api/import-exstatic', methods=['POST'])
931
1427
  def api_import_exstatic():
@@ -1111,4 +1607,127 @@ def register_database_api_routes(app):
1111
1607
 
1112
1608
  except Exception as e:
1113
1609
  logger.error(f"Error in ExStatic import: {e}")
1114
- return jsonify({'error': f'Import failed: {str(e)}'}), 500
1610
+ return jsonify({'error': f'Import failed: {str(e)}'}), 500
1611
+
1612
+ @app.route('/api/kanji-sorting-configs', methods=['GET'])
1613
+ def api_kanji_sorting_configs():
1614
+ """
1615
+ List available kanji sorting configuration JSON files.
1616
+ Returns metadata for each available sorting option.
1617
+ """
1618
+ try:
1619
+ # Get the kanji_grid directory path
1620
+ template_dir = Path(__file__).parent / 'templates' / 'components' / 'kanji_grid'
1621
+
1622
+ if not template_dir.exists():
1623
+ logger.warning(f"Kanji grid directory does not exist: {template_dir}")
1624
+ return jsonify({'configs': []}), 200
1625
+
1626
+ configs = []
1627
+
1628
+ # Scan for JSON files in the directory
1629
+ for json_file in template_dir.glob('*.json'):
1630
+ try:
1631
+ with open(json_file, 'r', encoding='utf-8') as f:
1632
+ data = json.load(f)
1633
+
1634
+ # Extract metadata from JSON
1635
+ configs.append({
1636
+ 'filename': json_file.name,
1637
+ 'name': data.get('name', json_file.stem),
1638
+ 'version': data.get('version', 1),
1639
+ 'lang': data.get('lang', 'ja'),
1640
+ 'source': data.get('source', ''),
1641
+ 'group_count': len(data.get('groups', []))
1642
+ })
1643
+ except json.JSONDecodeError as e:
1644
+ logger.warning(f"Failed to parse {json_file.name}: {e}")
1645
+ continue
1646
+ except Exception as e:
1647
+ logger.warning(f"Error reading {json_file.name}: {e}")
1648
+ continue
1649
+
1650
+ # Sort by name for consistency
1651
+ configs.sort(key=lambda x: x['name'])
1652
+
1653
+ return jsonify({'configs': configs}), 200
1654
+
1655
+ except Exception as e:
1656
+ logger.error(f"Error fetching kanji sorting configs: {e}")
1657
+ return jsonify({'error': 'Failed to fetch sorting configurations'}), 500
1658
+
1659
+ @app.route('/api/kanji-sorting-config/<filename>', methods=['GET'])
1660
+ def api_kanji_sorting_config(filename):
1661
+ """
1662
+ Get a specific kanji sorting configuration file.
1663
+ Returns the full JSON configuration.
1664
+ """
1665
+ try:
1666
+ # Sanitize filename to prevent path traversal
1667
+ if '..' in filename or '/' in filename or '\\' in filename:
1668
+ return jsonify({'error': 'Invalid filename'}), 400
1669
+
1670
+ if not filename.endswith('.json'):
1671
+ filename += '.json'
1672
+
1673
+ # Get the kanji_grid directory path
1674
+ template_dir = Path(__file__).parent / 'templates' / 'components' / 'kanji_grid'
1675
+ config_file = template_dir / filename
1676
+
1677
+ if not config_file.exists() or not config_file.is_file():
1678
+ return jsonify({'error': 'Configuration file not found'}), 404
1679
+
1680
+ # Read and return the JSON configuration
1681
+ with open(config_file, 'r', encoding='utf-8') as f:
1682
+ config_data = json.load(f)
1683
+
1684
+ return jsonify(config_data), 200
1685
+
1686
+ except json.JSONDecodeError as e:
1687
+ logger.error(f"Failed to parse {filename}: {e}")
1688
+ return jsonify({'error': 'Invalid JSON configuration'}), 500
1689
+ except Exception as e:
1690
+ logger.error(f"Error fetching config {filename}: {e}")
1691
+ return jsonify({'error': 'Failed to fetch configuration'}), 500
1692
+
1693
+ @app.route('/api/debug-db', methods=['GET'])
1694
+ def api_debug_db():
1695
+ """Debug endpoint to check database structure and content."""
1696
+ try:
1697
+ # Check table structure
1698
+ columns_info = GameLinesTable._db.fetchall("PRAGMA table_info(game_lines)")
1699
+ table_structure = [{'name': col[1], 'type': col[2], 'notnull': col[3], 'default': col[4]} for col in columns_info]
1700
+
1701
+ # Check if we have any data
1702
+ count_result = GameLinesTable._db.fetchone("SELECT COUNT(*) FROM game_lines")
1703
+ total_count = count_result[0] if count_result else 0
1704
+
1705
+ # Try to get a sample record
1706
+ sample_record = None
1707
+ if total_count > 0:
1708
+ sample_row = GameLinesTable._db.fetchone("SELECT * FROM game_lines LIMIT 1")
1709
+ if sample_row:
1710
+ sample_record = {
1711
+ 'row_length': len(sample_row),
1712
+ 'sample_data': sample_row[:5] if len(sample_row) > 5 else sample_row # First 5 columns only
1713
+ }
1714
+
1715
+ # Test the model
1716
+ model_info = {
1717
+ 'fields_count': len(GameLinesTable._fields),
1718
+ 'types_count': len(GameLinesTable._types),
1719
+ 'fields': GameLinesTable._fields,
1720
+ 'types': [str(t) for t in GameLinesTable._types]
1721
+ }
1722
+
1723
+ return jsonify({
1724
+ 'table_structure': table_structure,
1725
+ 'total_records': total_count,
1726
+ 'sample_record': sample_record,
1727
+ 'model_info': model_info
1728
+ }), 200
1729
+
1730
+ except Exception as e:
1731
+ logger.error(f"Error in debug endpoint: {e}")
1732
+ return jsonify({'error': f'Debug failed: {str(e)}'}), 500
1733
+