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.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,118 @@
1
+ """
2
+ Setup script to register and run the populate_games cron job once.
3
+
4
+ This script:
5
+ 1. Checks if populate_games cron already exists
6
+ 2. If not, creates it with schedule='once'
7
+ 3. Runs it immediately to populate the games table
8
+ 4. The cron will auto-disable after running (schedule='once' behavior)
9
+
10
+ Usage:
11
+ python -m GameSentenceMiner.util.cron.setup_populate_games_cron
12
+ """
13
+
14
+ import time
15
+ from GameSentenceMiner.util.cron_table import CronTable
16
+ from GameSentenceMiner.util.cron.populate_games import populate_games_table
17
+ from GameSentenceMiner.util.configuration import logger
18
+
19
+
20
+ def setup_and_run_populate_games():
21
+ """
22
+ Setup and run the populate_games cron job once.
23
+
24
+ Returns:
25
+ Dictionary with setup and execution results
26
+ """
27
+ logger.info("=" * 80)
28
+ logger.info("POPULATE GAMES CRON SETUP")
29
+ logger.info("=" * 80)
30
+
31
+ # Check if cron already exists
32
+ existing_cron = CronTable.get_by_name('populate_games')
33
+
34
+ if existing_cron:
35
+ if existing_cron.enabled:
36
+ logger.info(f"populate_games cron already exists and is enabled (id={existing_cron.id})")
37
+ logger.info("Running it now...")
38
+ else:
39
+ logger.info(f"populate_games cron already exists but is disabled (id={existing_cron.id})")
40
+ logger.info("This means it has already run once. Skipping...")
41
+ return {
42
+ 'setup': 'skipped',
43
+ 'reason': 'Cron already ran (disabled)',
44
+ 'cron_id': existing_cron.id
45
+ }
46
+ else:
47
+ # Create new cron entry with schedule='once'
48
+ logger.info("Creating new populate_games cron entry...")
49
+ try:
50
+ new_cron = CronTable.create_cron_entry(
51
+ name='populate_games',
52
+ description='One-time auto-creation of game records from game_lines',
53
+ next_run=time.time(), # Run immediately
54
+ schedule='once', # Will auto-disable after running
55
+ enabled=True
56
+ )
57
+ logger.info(f"Created populate_games cron (id={new_cron.id})")
58
+ existing_cron = new_cron
59
+ except Exception as e:
60
+ logger.error(f"Failed to create cron entry: {e}")
61
+ return {
62
+ 'setup': 'failed',
63
+ 'error': str(e)
64
+ }
65
+
66
+ # Run the populate_games function
67
+ logger.info("Executing populate_games_table()...")
68
+ try:
69
+ result = populate_games_table()
70
+
71
+ # Mark the cron as having run (will auto-disable since schedule='once')
72
+ CronTable.just_ran(existing_cron.id)
73
+
74
+ logger.info("=" * 80)
75
+ logger.info("POPULATE GAMES COMPLETED")
76
+ logger.info("=" * 80)
77
+ logger.info(f"Success: {result['success']}")
78
+ logger.info(f"Games created: {result['created']}")
79
+ logger.info(f"Lines linked: {result['linked_lines']}")
80
+ logger.info(f"Errors: {result['errors']}")
81
+ if result['error_message']:
82
+ logger.error(f"Error: {result['error_message']}")
83
+ logger.info("=" * 80)
84
+
85
+ return {
86
+ 'setup': 'success',
87
+ 'cron_id': existing_cron.id,
88
+ 'execution_result': result
89
+ }
90
+
91
+ except Exception as e:
92
+ logger.error(f"Failed to execute populate_games: {e}", exc_info=True)
93
+ return {
94
+ 'setup': 'execution_failed',
95
+ 'cron_id': existing_cron.id,
96
+ 'error': str(e)
97
+ }
98
+
99
+
100
+ if __name__ == '__main__':
101
+ result = setup_and_run_populate_games()
102
+
103
+ print("\n" + "=" * 80)
104
+ print("SETUP RESULT")
105
+ print("=" * 80)
106
+ print(f"Setup status: {result.get('setup')}")
107
+ if 'cron_id' in result:
108
+ print(f"Cron ID: {result['cron_id']}")
109
+ if 'execution_result' in result:
110
+ exec_result = result['execution_result']
111
+ print(f"Games created: {exec_result.get('created', 0)}")
112
+ print(f"Lines linked: {exec_result.get('linked_lines', 0)}")
113
+ print(f"Errors: {exec_result.get('errors', 0)}")
114
+ if 'error' in result:
115
+ print(f"Error: {result['error']}")
116
+ if 'reason' in result:
117
+ print(f"Reason: {result['reason']}")
118
+ print("=" * 80)
@@ -0,0 +1,334 @@
1
+ import time
2
+ from datetime import datetime, timedelta
3
+ from typing import Optional, List, Dict
4
+
5
+ from GameSentenceMiner.util.db import SQLiteDBTable
6
+ from GameSentenceMiner.util.configuration import logger
7
+
8
+
9
+ class CronTable(SQLiteDBTable):
10
+ """
11
+ Table for managing scheduled cron jobs in GSM.
12
+ Stores periodic tasks that need to be executed on a schedule.
13
+ """
14
+
15
+ _table = "cron_table"
16
+ _fields = [
17
+ "name",
18
+ "description",
19
+ "last_run",
20
+ "next_run",
21
+ "enabled",
22
+ "created_at",
23
+ "schedule",
24
+ ]
25
+ _types = [
26
+ int, # id (primary key)
27
+ str, # name
28
+ str, # description
29
+ float, # last_run (Unix timestamp)
30
+ float, # next_run (Unix timestamp)
31
+ bool, # enabled
32
+ float, # created_at (Unix timestamp)
33
+ str, # schedule (once, daily, weekly, monthly, yearly)
34
+ ]
35
+ _pk = "id"
36
+ _auto_increment = True
37
+
38
+ def __init__(
39
+ self,
40
+ id: Optional[int] = None,
41
+ name: Optional[str] = None,
42
+ description: Optional[str] = None,
43
+ last_run: Optional[float] = None,
44
+ next_run: Optional[float] = None,
45
+ enabled: bool = True,
46
+ created_at: Optional[float] = None,
47
+ schedule: str = "once",
48
+ ):
49
+ """
50
+ Initialize a CronTable entry.
51
+
52
+ Args:
53
+ id: Primary key (auto-generated if None)
54
+ name: Unique name for the cron job
55
+ description: Human-readable description
56
+ last_run: Unix timestamp of last execution (None if never run)
57
+ next_run: Unix timestamp for next scheduled run
58
+ enabled: Whether the cron job is active
59
+ created_at: Unix timestamp of creation (defaults to now)
60
+ schedule: Schedule type ('once', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly')
61
+ """
62
+ self.id = id
63
+ self.name = name if name else ""
64
+ self.description = description if description else ""
65
+ self.last_run = last_run # None if never run
66
+ self.next_run = next_run if next_run else time.time()
67
+ self.enabled = enabled
68
+ self.created_at = created_at if created_at else time.time()
69
+ self.schedule = (
70
+ schedule
71
+ if schedule in ["once", "minutely", "hourly", "daily", "weekly", "monthly", "yearly"]
72
+ else "once"
73
+ )
74
+
75
+ @classmethod
76
+ def create_cron_entry(
77
+ cls,
78
+ name: str,
79
+ description: str,
80
+ next_run: float,
81
+ schedule: str,
82
+ enabled: bool = True,
83
+ ) -> "CronTable":
84
+ """
85
+ Create a new cron entry and save it to the database.
86
+
87
+ Args:
88
+ name: Unique name for the cron job
89
+ description: Human-readable description
90
+ next_run: Unix timestamp for next scheduled run
91
+ schedule: Schedule type ('once', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly')
92
+ enabled: Whether the cron job is active (default: True)
93
+
94
+ Returns:
95
+ CronTable: The created cron entry
96
+
97
+ Raises:
98
+ ValueError: If schedule type is invalid or name already exists
99
+ """
100
+ # Validate schedule type
101
+ valid_schedules = ["once", "minutely", "hourly", "daily", "weekly", "monthly", "yearly"]
102
+ if schedule not in valid_schedules:
103
+ raise ValueError(
104
+ f"Invalid schedule type '{schedule}'. Must be one of: {', '.join(valid_schedules)}"
105
+ )
106
+
107
+ # Check if name already exists
108
+ existing = cls.get_by_name(name)
109
+ if existing:
110
+ raise ValueError(f"Cron job with name '{name}' already exists")
111
+
112
+ # Create new entry
113
+ new_cron = cls(
114
+ name=name,
115
+ description=description,
116
+ next_run=next_run,
117
+ schedule=schedule,
118
+ enabled=enabled,
119
+ created_at=time.time(),
120
+ )
121
+ new_cron.save()
122
+ logger.debug(
123
+ f"Created cron job '{name}' with schedule '{schedule}', next run at {datetime.fromtimestamp(next_run)}"
124
+ )
125
+ return new_cron
126
+
127
+ @classmethod
128
+ def get_due_crons(cls) -> List["CronTable"]:
129
+ """
130
+ Get all enabled cron jobs that are due to run now or earlier.
131
+
132
+ Returns:
133
+ List[CronTable]: List of cron jobs that need to be executed, ordered by next_run
134
+ """
135
+ now = time.time()
136
+ rows = cls._db.fetchall(
137
+ f"SELECT * FROM {cls._table} WHERE enabled=1 AND next_run <= ? ORDER BY next_run ASC",
138
+ (now,),
139
+ )
140
+ crons = [cls.from_row(row) for row in rows]
141
+ if crons:
142
+ logger.debug(f"Found {len(crons)} due cron job(s)")
143
+ return crons
144
+
145
+ @classmethod
146
+ def get_by_name(cls, name: str) -> Optional["CronTable"]:
147
+ """
148
+ Get a cron job by its unique name.
149
+
150
+ Args:
151
+ name: The name of the cron job
152
+
153
+ Returns:
154
+ CronTable: The cron job if found, None otherwise
155
+ """
156
+ row = cls._db.fetchone(f"SELECT * FROM {cls._table} WHERE name=?", (name,))
157
+ return cls.from_row(row) if row else None
158
+
159
+ @classmethod
160
+ def get_all_enabled(cls) -> List["CronTable"]:
161
+ """
162
+ Get all enabled cron jobs.
163
+
164
+ Returns:
165
+ List[CronTable]: List of all enabled cron jobs
166
+ """
167
+ rows = cls._db.fetchall(
168
+ f"SELECT * FROM {cls._table} WHERE enabled=1 ORDER BY next_run ASC"
169
+ )
170
+ return [cls.from_row(row) for row in rows]
171
+
172
+ def update_last_run(self, timestamp: Optional[float] = None):
173
+ """
174
+ Update the last_run timestamp for this cron job.
175
+
176
+ Args:
177
+ timestamp: Unix timestamp to set (defaults to current time)
178
+ """
179
+ self.last_run = timestamp if timestamp is not None else time.time()
180
+ self.save()
181
+ logger.debug(
182
+ f"Updated last_run for cron job '{self.name}' to {datetime.fromtimestamp(self.last_run)}"
183
+ )
184
+
185
+ def update_next_run(self, next_run: float):
186
+ """
187
+ Update the next_run timestamp for this cron job.
188
+
189
+ Args:
190
+ next_run: Unix timestamp for next scheduled run
191
+ """
192
+ self.next_run = next_run
193
+ self.save()
194
+ logger.debug(
195
+ f"Updated next_run for cron job '{self.name}' to {datetime.fromtimestamp(next_run)}"
196
+ )
197
+
198
+ def enable(self):
199
+ """Enable this cron job."""
200
+ self.enabled = True
201
+ self.save()
202
+ logger.debug(f"Enabled cron job '{self.name}'")
203
+
204
+ def disable(self):
205
+ """Disable this cron job."""
206
+ self.enabled = False
207
+ self.save()
208
+ logger.debug(f"Disabled cron job '{self.name}'")
209
+
210
+ @classmethod
211
+ def enable_cron(cls, cron_id: int):
212
+ """
213
+ Enable a cron job by ID.
214
+
215
+ Args:
216
+ cron_id: The ID of the cron job to enable
217
+ """
218
+ cron = cls.get(cron_id)
219
+ if cron:
220
+ cron.enable()
221
+ else:
222
+ logger.warning(f"Cron job with id {cron_id} not found")
223
+
224
+ @classmethod
225
+ def disable_cron(cls, cron_id: int):
226
+ """
227
+ Disable a cron job by ID.
228
+
229
+ Args:
230
+ cron_id: The ID of the cron job to disable
231
+ """
232
+ cron = cls.get(cron_id)
233
+ if cron:
234
+ cron.disable()
235
+ else:
236
+ logger.warning(f"Cron job with id {cron_id} not found")
237
+
238
+ @classmethod
239
+ def just_ran(cls, cron_id: int):
240
+ """
241
+ Mark a cron job as having just run and calculate the next run time based on its schedule.
242
+
243
+ This is a convenience method that:
244
+ 1. Sets last_run to current time
245
+ 2. Calculates next_run based on the schedule type
246
+ 3. Updates the database
247
+
248
+ For 'once' schedule, the cron job will be disabled after running.
249
+
250
+ Args:
251
+ cron_id: The ID of the cron job that just ran
252
+ """
253
+ cron = cls.get(cron_id)
254
+ if not cron:
255
+ logger.warning(f"Cron job with id {cron_id} not found")
256
+ return
257
+
258
+ # Set last_run to now
259
+ now = time.time()
260
+ cron.last_run = now
261
+
262
+ # Calculate next_run based on schedule
263
+ now_dt = datetime.fromtimestamp(now)
264
+
265
+ if cron.schedule == "once":
266
+ # For one-time jobs, disable after running
267
+ cron.enabled = False
268
+ cron.next_run = now # Set to now since it won't run again
269
+ logger.debug(
270
+ f"Cron job '{cron.name}' completed (one-time job) and has been disabled"
271
+ )
272
+ elif cron.schedule == "minutely":
273
+ # Schedule for 1 minute from now
274
+ next_run_dt = now_dt + timedelta(minutes=1)
275
+ cron.next_run = next_run_dt.timestamp()
276
+ logger.debug(
277
+ f"Cron job '{cron.name}' completed, next run scheduled for {next_run_dt}"
278
+ )
279
+ elif cron.schedule == "hourly":
280
+ # Schedule for 1 hour from now
281
+ next_run_dt = now_dt + timedelta(hours=1)
282
+ cron.next_run = next_run_dt.timestamp()
283
+ logger.debug(
284
+ f"Cron job '{cron.name}' completed, next run scheduled for {next_run_dt}"
285
+ )
286
+ elif cron.schedule == "daily":
287
+ # Schedule for 3am tomorrow
288
+ # If we schedule at + 24 hours
289
+ # imagine if user opens gsm at like 6pm first time, does some mining
290
+ # tomorrow they open gsm again but at 9am, but the cron is set to run at 6pm
291
+ # so they will have stats from yesterday not rolled up, as stats rollup did not run
292
+ # setting it to 3am means the user always has the full previous day rolled up when they open gsm
293
+ next_run_dt = (now_dt + timedelta(days=1)).replace(
294
+ hour=3, minute=0, second=0, microsecond=0
295
+ )
296
+ cron.next_run = next_run_dt.timestamp()
297
+ logger.debug(
298
+ f"Cron job '{cron.name}' completed, next run scheduled for {next_run_dt}"
299
+ )
300
+ elif cron.schedule == "weekly":
301
+ # Schedule for 3am next week (same day)
302
+ next_run_dt = (now_dt + timedelta(weeks=1)).replace(
303
+ hour=3, minute=0, second=0, microsecond=0
304
+ )
305
+ cron.next_run = next_run_dt.timestamp()
306
+ logger.debug(
307
+ f"Cron job '{cron.name}' completed, next run scheduled for {next_run_dt}"
308
+ )
309
+ elif cron.schedule == "monthly":
310
+ # Schedule for 3am approximately 30 days from now
311
+ next_run_dt = (now_dt + timedelta(days=30)).replace(
312
+ hour=3, minute=0, second=0, microsecond=0
313
+ )
314
+ cron.next_run = next_run_dt.timestamp()
315
+ logger.debug(
316
+ f"Cron job '{cron.name}' completed, next run scheduled for {next_run_dt}"
317
+ )
318
+ elif cron.schedule == "yearly":
319
+ # Schedule for 3am approximately 365 days from now
320
+ next_run_dt = (now_dt + timedelta(days=365)).replace(
321
+ hour=3, minute=0, second=0, microsecond=0
322
+ )
323
+ cron.next_run = next_run_dt.timestamp()
324
+ logger.debug(
325
+ f"Cron job '{cron.name}' completed, next run scheduled for {next_run_dt}"
326
+ )
327
+ else:
328
+ logger.warning(
329
+ f"Unknown schedule type '{cron.schedule}' for cron job '{cron.name}'"
330
+ )
331
+ return
332
+
333
+ # Save all changes
334
+ cron.save()