GameSentenceMiner 2.18.15__py3-none-any.whl → 2.18.16__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 (34) hide show
  1. GameSentenceMiner/anki.py +8 -53
  2. GameSentenceMiner/ui/anki_confirmation.py +16 -2
  3. GameSentenceMiner/util/db.py +11 -7
  4. GameSentenceMiner/util/games_table.py +320 -0
  5. GameSentenceMiner/web/anki_api_endpoints.py +506 -0
  6. GameSentenceMiner/web/database_api.py +239 -117
  7. GameSentenceMiner/web/static/css/loading-skeleton.css +41 -0
  8. GameSentenceMiner/web/static/css/search.css +54 -0
  9. GameSentenceMiner/web/static/css/stats.css +76 -0
  10. GameSentenceMiner/web/static/js/anki_stats.js +304 -50
  11. GameSentenceMiner/web/static/js/database.js +44 -7
  12. GameSentenceMiner/web/static/js/heatmap.js +326 -0
  13. GameSentenceMiner/web/static/js/overview.js +20 -224
  14. GameSentenceMiner/web/static/js/search.js +190 -23
  15. GameSentenceMiner/web/static/js/stats.js +371 -1
  16. GameSentenceMiner/web/stats.py +188 -0
  17. GameSentenceMiner/web/templates/anki_stats.html +145 -58
  18. GameSentenceMiner/web/templates/components/date-range.html +19 -0
  19. GameSentenceMiner/web/templates/components/html-head.html +45 -0
  20. GameSentenceMiner/web/templates/components/js-config.html +37 -0
  21. GameSentenceMiner/web/templates/components/popups.html +15 -0
  22. GameSentenceMiner/web/templates/components/settings-modal.html +233 -0
  23. GameSentenceMiner/web/templates/database.html +13 -3
  24. GameSentenceMiner/web/templates/goals.html +9 -31
  25. GameSentenceMiner/web/templates/overview.html +16 -223
  26. GameSentenceMiner/web/templates/search.html +46 -0
  27. GameSentenceMiner/web/templates/stats.html +49 -311
  28. GameSentenceMiner/web/texthooking_page.py +4 -66
  29. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.16.dist-info}/METADATA +1 -1
  30. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.16.dist-info}/RECORD +34 -25
  31. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.16.dist-info}/WHEEL +0 -0
  32. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.16.dist-info}/entry_points.txt +0 -0
  33. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.16.dist-info}/licenses/LICENSE +0 -0
  34. {gamesentenceminer-2.18.15.dist-info → gamesentenceminer-2.18.16.dist-info}/top_level.txt +0 -0
@@ -12,6 +12,27 @@ document.addEventListener('DOMContentLoaded', function () {
12
12
  const ankiCoverage = document.getElementById('ankiCoverage');
13
13
  const fromDateInput = document.getElementById('fromDate');
14
14
  const toDateInput = document.getElementById('toDate');
15
+ const ankiConnectWarning = document.getElementById('ankiConnectWarning');
16
+
17
+ // Function to show/hide AnkiConnect warning
18
+ function showAnkiConnectWarning(show) {
19
+ if (ankiConnectWarning) {
20
+ ankiConnectWarning.style.display = show ? 'block' : 'none';
21
+ }
22
+ }
23
+
24
+ // Initialize heatmap renderer with mining-specific configuration
25
+ const miningHeatmapRenderer = new HeatmapRenderer({
26
+ containerId: 'miningHeatmapContainer',
27
+ metricName: 'sentences',
28
+ metricLabel: 'sentences mined'
29
+ });
30
+
31
+ // Function to create GitHub-style heatmap for mining activity using shared component
32
+ function createMiningHeatmap(heatmapData) {
33
+ miningHeatmapRenderer.render(heatmapData);
34
+ }
35
+
15
36
 
16
37
  console.log('Found DOM elements:', {
17
38
  loading, error, missingKanjiGrid, missingKanjiCount,
@@ -50,8 +71,13 @@ document.addEventListener('DOMContentLoaded', function () {
50
71
  missingKanjiCount
51
72
  });
52
73
 
53
- if (ankiTotalKanji) ankiTotalKanji.textContent = data.anki_kanji_count;
54
- if (gsmTotalKanji) gsmTotalKanji.textContent = data.gsm_kanji_count;
74
+ // Remove loading skeletons and update values
75
+ if (ankiTotalKanji) {
76
+ ankiTotalKanji.innerHTML = data.anki_kanji_count;
77
+ }
78
+ if (gsmTotalKanji) {
79
+ gsmTotalKanji.innerHTML = data.gsm_kanji_count;
80
+ }
55
81
  if (ankiCoverage) {
56
82
  const gsmCount = Number(data.gsm_kanji_count);
57
83
  const missingCount = Array.isArray(data.missing_kanji) ? data.missing_kanji.length : 0;
@@ -59,34 +85,15 @@ document.addEventListener('DOMContentLoaded', function () {
59
85
  if (gsmCount > 0) {
60
86
  percent = ((gsmCount - missingCount) / gsmCount) * 100;
61
87
  }
62
- ankiCoverage.textContent = percent.toFixed(1) + '%';
88
+ ankiCoverage.innerHTML = percent.toFixed(1) + '%';
89
+ }
90
+ if (missingKanjiCount) {
91
+ const missingCount = Array.isArray(data.missing_kanji) ? data.missing_kanji.length : 0;
92
+ missingKanjiCount.innerHTML = missingCount;
63
93
  }
64
94
  renderKanjiGrid(data.missing_kanji);
65
95
  }
66
96
 
67
- async function loadStats(start_timestamp = null, end_timestamp = null) {
68
- console.log('Loading Anki stats...');
69
- showLoading(true);
70
- showError(false);
71
- try {
72
- // Build URL with optional query params
73
- const params = new URLSearchParams();
74
- if (start_timestamp) params.append('start_timestamp', start_timestamp);
75
- if (end_timestamp) params.append('end_timestamp', end_timestamp);
76
- const url = '/api/anki_stats' + (params.toString() ? `?${params.toString()}` : '');
77
-
78
- const resp = await fetch(url);
79
- if (!resp.ok) throw new Error('Failed to load');
80
- const data = await resp.json();
81
- console.log('Received data:', data);
82
- updateStats(data);
83
- } catch (e) {
84
- console.error('Failed to load Anki stats:', e);
85
- showError(true);
86
- } finally {
87
- showLoading(false);
88
- }
89
- }
90
97
 
91
98
  function getUnixTimestampsInMilliseconds(startDate, endDate) {
92
99
  // Parse the start date and create a Date object at the beginning of the day
@@ -100,42 +107,172 @@ document.addEventListener('DOMContentLoaded', function () {
100
107
  return { startTimestamp, endTimestamp };
101
108
  }
102
109
 
110
+ // Progressive data loading function - loads each section independently
111
+ async function loadAllStats(start_timestamp = null, end_timestamp = null) {
112
+ console.log('Loading Anki stats with progressive loading...');
113
+ showLoading(true);
114
+ showError(false);
115
+
116
+ // Show all loading spinners
117
+ const gameStatsLoading = document.getElementById('gameStatsLoading');
118
+ const nsfwSfwRetentionLoading = document.getElementById('nsfwSfwRetentionLoading');
119
+ if (gameStatsLoading) gameStatsLoading.style.display = 'flex';
120
+ if (nsfwSfwRetentionLoading) nsfwSfwRetentionLoading.style.display = 'flex';
121
+
122
+ // Build query parameters
123
+ const params = new URLSearchParams();
124
+ if (start_timestamp) params.append('start_timestamp', start_timestamp);
125
+ if (end_timestamp) params.append('end_timestamp', end_timestamp);
126
+ const queryString = params.toString() ? `?${params.toString()}` : '';
127
+
128
+ // Load sections progressively and concurrently
129
+ const loadPromises = [
130
+ loadKanjiStats(queryString),
131
+ loadGameStats(queryString),
132
+ // loadNsfwSfwRetention(queryString),
133
+ loadMiningHeatmap(queryString)
134
+ ];
135
+
136
+ // Wait for all sections to complete
137
+ try {
138
+ await Promise.allSettled(loadPromises);
139
+ showAnkiConnectWarning(false);
140
+ } catch (e) {
141
+ console.error('Some stats failed to load:', e);
142
+ showAnkiConnectWarning(true);
143
+ } finally {
144
+ showLoading(false);
145
+ // Hide loading spinners
146
+ if (gameStatsLoading) gameStatsLoading.style.display = 'none';
147
+ if (nsfwSfwRetentionLoading) nsfwSfwRetentionLoading.style.display = 'none';
148
+
149
+ // Show tables/grids
150
+ const gameStatsTable = document.getElementById('gameStatsTable');
151
+ const nsfwSfwRetentionStats = document.getElementById('nsfwSfwRetentionStats');
152
+ if (gameStatsTable) gameStatsTable.style.display = 'table';
153
+ if (nsfwSfwRetentionStats) nsfwSfwRetentionStats.style.display = 'grid';
154
+ }
155
+ }
156
+
157
+ // Individual loading functions for each section
158
+ async function loadKanjiStats(queryString) {
159
+ try {
160
+ const resp = await fetch(`/api/anki_kanji_stats${queryString}`);
161
+ if (!resp.ok) throw new Error('Failed to load kanji stats');
162
+ const data = await resp.json();
163
+ console.log('Received kanji data:', data);
164
+ updateStats(data);
165
+ } catch (e) {
166
+ console.error('Failed to load kanji stats:', e);
167
+ // Show error in kanji section
168
+ const missingKanjiCount = document.getElementById('missingKanjiCount');
169
+ if (missingKanjiCount) missingKanjiCount.textContent = 'Error';
170
+ }
171
+ }
172
+
173
+ async function loadGameStats(queryString) {
174
+ try {
175
+ const resp = await fetch(`/api/anki_game_stats${queryString}`);
176
+ if (!resp.ok) throw new Error('Failed to load game stats');
177
+ const data = await resp.json();
178
+ console.log('Received game stats data:', data);
179
+ renderGameStatsTable(data);
180
+ } catch (e) {
181
+ console.error('Failed to load game stats:', e);
182
+ const gameStatsEmpty = document.getElementById('gameStatsEmpty');
183
+ if (gameStatsEmpty) {
184
+ gameStatsEmpty.style.display = 'block';
185
+ gameStatsEmpty.textContent = 'Failed to load game statistics. Make sure Anki is running with AnkiConnect.';
186
+ }
187
+ }
188
+ }
189
+
190
+ async function loadNsfwSfwRetention(queryString) {
191
+ try {
192
+ const resp = await fetch(`/api/anki_nsfw_sfw_retention${queryString}`);
193
+ if (!resp.ok) throw new Error('Failed to load NSFW/SFW retention');
194
+ const data = await resp.json();
195
+ console.log('Received NSFW/SFW retention data:', data);
196
+ renderNsfwSfwRetention(data);
197
+ } catch (e) {
198
+ console.error('Failed to load NSFW/SFW retention:', e);
199
+ const nsfwSfwRetentionEmpty = document.getElementById('nsfwSfwRetentionEmpty');
200
+ if (nsfwSfwRetentionEmpty) {
201
+ nsfwSfwRetentionEmpty.style.display = 'block';
202
+ nsfwSfwRetentionEmpty.textContent = 'Failed to load retention statistics. Make sure Anki is running with AnkiConnect.';
203
+ }
204
+ }
205
+ }
206
+
207
+ async function loadMiningHeatmap(queryString) {
208
+ try {
209
+ const resp = await fetch(`/api/anki_mining_heatmap${queryString}`);
210
+ if (!resp.ok) throw new Error('Failed to load mining heatmap');
211
+ const data = await resp.json();
212
+ console.log('Received mining heatmap data:', data);
213
+
214
+ if (data && Object.keys(data).length > 0) {
215
+ createMiningHeatmap(data);
216
+ } else {
217
+ const container = document.getElementById('miningHeatmapContainer');
218
+ container.innerHTML = '<p style="text-align: center; color: var(--text-tertiary); padding: 20px;">No mining data available for the selected date range.</p>';
219
+ }
220
+ } catch (e) {
221
+ console.error('Failed to load mining heatmap:', e);
222
+ const container = document.getElementById('miningHeatmapContainer');
223
+ container.innerHTML = '<p style="text-align: center; color: var(--text-tertiary); padding: 20px;">Failed to load mining heatmap.</p>';
224
+ }
225
+ }
226
+
103
227
  document.addEventListener("datesSetAnki", () => {
104
228
  const fromDate = sessionStorage.getItem("fromDateAnki");
105
229
  const toDate = sessionStorage.getItem("toDateAnki");
106
230
  const { startTimestamp, endTimestamp } = getUnixTimestampsInMilliseconds(fromDate, toDate);
107
231
 
108
- loadStats(startTimestamp, endTimestamp);
232
+ // Use unified endpoint instead of multiple calls
233
+ loadAllStats(startTimestamp, endTimestamp);
109
234
  });
110
235
 
111
- function initializeDates() {
236
+ async function initializeDates() {
112
237
  const fromDateInput = document.getElementById('fromDate');
113
238
  const toDateInput = document.getElementById('toDate');
114
239
 
115
240
  const fromDate = sessionStorage.getItem("fromDateAnki");
116
- const toDate = sessionStorage.getItem("toDateAnki");
241
+ const toDate = sessionStorage.getItem("toDateAnki");
117
242
 
118
243
  if (!(fromDate && toDate)) {
119
- fetch('/api/anki_earliest_date')
120
- .then(response => response.json())
121
- .then(response_json => {
122
- // Get first date in ms from API
123
- const firstDateinMs = response_json.earliest_card;
124
- const firstDateObject = new Date(firstDateinMs);
125
- const fromDate = firstDateObject.toLocaleDateString('en-CA');
126
- fromDateInput.value = fromDate;
127
-
128
- // Get today's date
129
- const today = new Date();
130
- const toDate = today.toLocaleDateString('en-CA');
131
- toDateInput.value = toDate;
132
-
133
- // Save in sessionStorage
134
- sessionStorage.setItem("fromDateAnki", fromDate);
135
- sessionStorage.setItem("toDateAnki", toDate);
136
-
137
- document.dispatchEvent(new Event("datesSetAnki"));
138
- });
244
+ try {
245
+ // Fetch earliest date from the dedicated endpoint
246
+ const resp = await fetch('/api/anki_earliest_date');
247
+ const data = await resp.json();
248
+
249
+ // Get first date in ms from API
250
+ const firstDateinMs = data.earliest_date;
251
+ const firstDateObject = new Date(firstDateinMs);
252
+ const fromDate = firstDateObject.toLocaleDateString('en-CA');
253
+ fromDateInput.value = fromDate;
254
+
255
+ // Get today's date
256
+ const today = new Date();
257
+ const toDate = today.toLocaleDateString('en-CA');
258
+ toDateInput.value = toDate;
259
+
260
+ // Save in sessionStorage
261
+ sessionStorage.setItem("fromDateAnki", fromDate);
262
+ sessionStorage.setItem("toDateAnki", toDate);
263
+
264
+ document.dispatchEvent(new Event("datesSetAnki"));
265
+ } catch (e) {
266
+ console.error('Failed to initialize dates:', e);
267
+ // Fallback to today if API fails
268
+ const today = new Date();
269
+ const todayStr = today.toLocaleDateString('en-CA');
270
+ fromDateInput.value = todayStr;
271
+ toDateInput.value = todayStr;
272
+ sessionStorage.setItem("fromDateAnki", todayStr);
273
+ sessionStorage.setItem("toDateAnki", todayStr);
274
+ document.dispatchEvent(new Event("datesSetAnki"));
275
+ }
139
276
  } else {
140
277
  // If values already in sessionStorage, set inputs from there
141
278
  fromDateInput.value = fromDate;
@@ -160,11 +297,128 @@ document.addEventListener('DOMContentLoaded', function () {
160
297
 
161
298
  const { startTimestamp, endTimestamp } = getUnixTimestampsInMilliseconds(fromDateStr, toDateStr);
162
299
 
163
- loadStats(startTimestamp, endTimestamp)
300
+ // Use unified endpoint instead of multiple calls
301
+ loadAllStats(startTimestamp, endTimestamp);
164
302
  }
165
303
 
166
304
  fromDateInput.addEventListener("change", handleDateChange);
167
305
  toDateInput.addEventListener("change", handleDateChange);
168
306
 
169
307
  initializeDates();
308
+
309
+ function renderGameStatsTable(gameStats) {
310
+ const gameStatsTableBody = document.getElementById('gameStatsTableBody');
311
+ const gameStatsEmpty = document.getElementById('gameStatsEmpty');
312
+
313
+ if (!gameStats || gameStats.length === 0) {
314
+ gameStatsTableBody.innerHTML = '';
315
+ gameStatsEmpty.style.display = 'block';
316
+ return;
317
+ }
318
+
319
+ gameStatsEmpty.style.display = 'none';
320
+
321
+ // Clear existing rows
322
+ gameStatsTableBody.innerHTML = '';
323
+
324
+ // Populate table with game stats
325
+ gameStats.forEach(game => {
326
+ const row = document.createElement('tr');
327
+
328
+ // Game name cell
329
+ const nameCell = document.createElement('td');
330
+ nameCell.textContent = game.game_name;
331
+ row.appendChild(nameCell);
332
+
333
+ // Average time per card cell
334
+ const timeCell = document.createElement('td');
335
+ timeCell.textContent = formatTime(game.avg_time_per_card);
336
+ row.appendChild(timeCell);
337
+
338
+ // Retention percentage cell
339
+ const retentionCell = document.createElement('td');
340
+ retentionCell.textContent = game.retention_pct + '%';
341
+ retentionCell.style.color = getRetentionColor(game.retention_pct);
342
+ row.appendChild(retentionCell);
343
+
344
+ gameStatsTableBody.appendChild(row);
345
+ });
346
+ }
347
+
348
+ function getRetentionColor(retention) {
349
+ if (retention >= 80) {
350
+ return 'var(--success-color, #2ecc71)';
351
+ } else if (retention >= 70) {
352
+ return 'var(--warning-color, #f39c12)';
353
+ } else {
354
+ return 'var(--danger-color, #e74c3c)';
355
+ }
356
+ }
357
+
358
+ function formatTime(seconds) {
359
+ if (seconds < 1) {
360
+ return (seconds * 1000).toFixed(0) + 'ms';
361
+ } else if (seconds < 60) {
362
+ return seconds.toFixed(1) + 's';
363
+ } else {
364
+ const minutes = Math.floor(seconds / 60);
365
+ const remainingSeconds = Math.floor(seconds % 60);
366
+ return `${minutes}m ${remainingSeconds}s`;
367
+ }
368
+ }
369
+
370
+ // Note: Old individual loading functions (loadGameStats, loadNsfwSfwRetention, loadStats, loadMiningHeatmap)
371
+ // have been replaced by the unified loadAllStats function for better performance
372
+
373
+ function renderNsfwSfwRetention(data) {
374
+ const nsfwRetentionEl = document.getElementById('nsfwRetention');
375
+ const sfwRetentionEl = document.getElementById('sfwRetention');
376
+ const nsfwReviewsEl = document.getElementById('nsfwReviews');
377
+ const sfwReviewsEl = document.getElementById('sfwReviews');
378
+ const nsfwAvgTimeEl = document.getElementById('nsfwAvgTime');
379
+ const sfwAvgTimeEl = document.getElementById('sfwAvgTime');
380
+ const nsfwSfwRetentionEmpty = document.getElementById('nsfwSfwRetentionEmpty');
381
+
382
+ // Check if we have any data
383
+ if (data.nsfw_reviews === 0 && data.sfw_reviews === 0) {
384
+ nsfwSfwRetentionEmpty.style.display = 'block';
385
+ document.getElementById('nsfwSfwRetentionStats').style.display = 'none';
386
+ return;
387
+ }
388
+
389
+ nsfwSfwRetentionEmpty.style.display = 'none';
390
+
391
+ // Update NSFW retention (remove skeleton and set content)
392
+ if (data.nsfw_reviews > 0) {
393
+ nsfwRetentionEl.innerHTML = data.nsfw_retention + '%';
394
+ nsfwRetentionEl.style.color = getRetentionColor(data.nsfw_retention);
395
+ nsfwReviewsEl.textContent = data.nsfw_reviews + ' reviews';
396
+ nsfwAvgTimeEl.innerHTML = formatTime(data.nsfw_avg_time);
397
+ nsfwAvgTimeEl.style.color = 'var(--text-primary)';
398
+ } else {
399
+ nsfwRetentionEl.innerHTML = 'N/A';
400
+ nsfwRetentionEl.style.color = 'var(--text-tertiary)';
401
+ nsfwReviewsEl.textContent = 'No reviews';
402
+ nsfwAvgTimeEl.innerHTML = 'N/A';
403
+ nsfwAvgTimeEl.style.color = 'var(--text-tertiary)';
404
+ }
405
+
406
+ // Update SFW retention (remove skeleton and set content)
407
+ if (data.sfw_reviews > 0) {
408
+ sfwRetentionEl.innerHTML = data.sfw_retention + '%';
409
+ sfwRetentionEl.style.color = getRetentionColor(data.sfw_retention);
410
+ sfwReviewsEl.textContent = data.sfw_reviews + ' reviews';
411
+ sfwAvgTimeEl.innerHTML = formatTime(data.sfw_avg_time);
412
+ sfwAvgTimeEl.style.color = 'var(--text-primary)';
413
+ } else {
414
+ sfwRetentionEl.innerHTML = 'N/A';
415
+ sfwRetentionEl.style.color = 'var(--text-tertiary)';
416
+ sfwReviewsEl.textContent = 'No reviews';
417
+ sfwAvgTimeEl.innerHTML = 'N/A';
418
+ sfwAvgTimeEl.style.color = 'var(--text-tertiary)';
419
+ }
420
+ }
421
+
422
+ // Note: NSFW/SFW retention stats are now loaded via the unified loadAllStats function
423
+ // which is triggered by the "datesSetAnki" event listener above (line 218-225)
170
424
  });
@@ -90,6 +90,12 @@ class DatabaseManager {
90
90
  if (removeDuplicatesBtn) {
91
91
  removeDuplicatesBtn.addEventListener('click', removeDuplicates);
92
92
  }
93
+
94
+ // Add event listener for the ignore time window checkbox
95
+ const ignoreTimeWindowCheckbox = document.getElementById('ignoreTimeWindow');
96
+ if (ignoreTimeWindowCheckbox) {
97
+ ignoreTimeWindowCheckbox.addEventListener('change', toggleTimeWindowVisibility);
98
+ }
93
99
  }
94
100
 
95
101
  async loadDashboardStats() {
@@ -444,8 +450,28 @@ async function openDeduplicationModal() {
444
450
  await loadGamesForDeduplication();
445
451
  // Reset modal state
446
452
  document.getElementById('timeWindow').value = '5';
453
+ document.getElementById('ignoreTimeWindow').checked = false;
447
454
  document.getElementById('deduplicationStats').style.display = 'none';
448
455
  document.getElementById('removeDuplicatesBtn').disabled = true;
456
+ document.getElementById('deduplicationError').style.display = 'none';
457
+ document.getElementById('deduplicationSuccess').style.display = 'none';
458
+ // Ensure time window is visible on modal open
459
+ toggleTimeWindowVisibility();
460
+ }
461
+
462
+ function toggleTimeWindowVisibility() {
463
+ const ignoreTimeWindow = document.getElementById('ignoreTimeWindow').checked;
464
+ const timeWindowGroup = document.getElementById('timeWindowGroup');
465
+
466
+ if (ignoreTimeWindow) {
467
+ timeWindowGroup.style.opacity = '0.5';
468
+ timeWindowGroup.style.pointerEvents = 'none';
469
+ document.getElementById('timeWindow').disabled = true;
470
+ } else {
471
+ timeWindowGroup.style.opacity = '1';
472
+ timeWindowGroup.style.pointerEvents = 'auto';
473
+ document.getElementById('timeWindow').disabled = false;
474
+ }
449
475
  }
450
476
 
451
477
  async function loadGamesForDeduplication() {
@@ -474,6 +500,7 @@ async function scanForDuplicates() {
474
500
  const selectedGames = Array.from(document.getElementById('gameSelection').selectedOptions).map(option => option.value);
475
501
  const timeWindow = parseInt(document.getElementById('timeWindow').value);
476
502
  const caseSensitive = document.getElementById('caseSensitiveDedup').checked;
503
+ const ignoreTimeWindow = document.getElementById('ignoreTimeWindow').checked;
477
504
  const statsDiv = document.getElementById('deduplicationStats');
478
505
  const errorDiv = document.getElementById('deduplicationError');
479
506
  const successDiv = document.getElementById('deduplicationSuccess');
@@ -491,8 +518,9 @@ async function scanForDuplicates() {
491
518
  return;
492
519
  }
493
520
 
494
- if (isNaN(timeWindow) || timeWindow < 1 || timeWindow > 1440) {
495
- errorDiv.textContent = 'Time window must be between 1 and 1440 minutes';
521
+ // Only validate time window if not ignoring it
522
+ if (!ignoreTimeWindow && (isNaN(timeWindow) || timeWindow < 1)) {
523
+ errorDiv.textContent = 'Time window must be at least 1 minute';
496
524
  errorDiv.style.display = 'block';
497
525
  return;
498
526
  }
@@ -502,6 +530,7 @@ async function scanForDuplicates() {
502
530
  games: selectedGames,
503
531
  time_window_minutes: timeWindow,
504
532
  case_sensitive: caseSensitive,
533
+ ignore_time_window: ignoreTimeWindow,
505
534
  preview_only: true
506
535
  };
507
536
 
@@ -533,10 +562,12 @@ async function scanForDuplicates() {
533
562
  removeBtn.disabled = result.duplicates_count === 0;
534
563
 
535
564
  if (result.duplicates_count > 0) {
536
- successDiv.textContent = `Found ${result.duplicates_count} duplicate sentences ready for removal.`;
565
+ const modeText = ignoreTimeWindow ? 'across entire games' : `within ${timeWindow} minute time window`;
566
+ successDiv.textContent = `Found ${result.duplicates_count} duplicate sentences ${modeText} ready for removal.`;
537
567
  successDiv.style.display = 'block';
538
568
  } else {
539
- successDiv.textContent = 'No duplicates found in the selected games within the specified time window.';
569
+ const modeText = ignoreTimeWindow ? 'across entire games' : 'within the specified time window';
570
+ successDiv.textContent = `No duplicates found in the selected games ${modeText}.`;
540
571
  successDiv.style.display = 'block';
541
572
  }
542
573
  } else {
@@ -553,7 +584,8 @@ async function scanForDuplicates() {
553
584
 
554
585
  statsDiv.style.display = 'block';
555
586
  removeBtn.disabled = false;
556
- successDiv.textContent = `Preview feature ready - found ${duplicatesFound} potential duplicates (backend endpoint needed)`;
587
+ const modeText = ignoreTimeWindow ? 'across entire games' : 'with time window';
588
+ successDiv.textContent = `Preview feature ready - found ${duplicatesFound} potential duplicates ${modeText} (backend endpoint needed)`;
557
589
  successDiv.style.display = 'block';
558
590
  }
559
591
  }
@@ -563,8 +595,10 @@ async function removeDuplicates() {
563
595
  const timeWindow = parseInt(document.getElementById('timeWindow').value);
564
596
  const caseSensitive = document.getElementById('caseSensitiveDedup').checked;
565
597
  const preserveNewest = document.getElementById('preserveNewest').checked;
598
+ const ignoreTimeWindow = document.getElementById('ignoreTimeWindow').checked;
566
599
 
567
- if (!confirm('This will permanently remove duplicate sentences. Continue?')) {
600
+ const modeText = ignoreTimeWindow ? 'ALL duplicate sentences across entire games' : 'duplicate sentences within the time window';
601
+ if (!confirm(`This will permanently remove ${modeText}. Continue?`)) {
568
602
  return;
569
603
  }
570
604
 
@@ -574,6 +608,7 @@ async function removeDuplicates() {
574
608
  time_window_minutes: timeWindow,
575
609
  case_sensitive: caseSensitive,
576
610
  preserve_newest: preserveNewest,
611
+ ignore_time_window: ignoreTimeWindow,
577
612
  preview_only: false
578
613
  };
579
614
 
@@ -587,7 +622,8 @@ async function removeDuplicates() {
587
622
 
588
623
  if (response.ok) {
589
624
  const successDiv = document.getElementById('deduplicationSuccess');
590
- successDiv.textContent = `Successfully removed ${result.deleted_count} duplicate sentences!`;
625
+ const resultModeText = ignoreTimeWindow ? 'across entire games' : `within ${timeWindow} minute time window`;
626
+ successDiv.textContent = `Successfully removed ${result.deleted_count} duplicate sentences ${resultModeText}!`;
591
627
  successDiv.style.display = 'block';
592
628
  document.getElementById('removeDuplicatesBtn').disabled = true;
593
629
  // Refresh dashboard stats
@@ -607,6 +643,7 @@ async function removeDuplicates() {
607
643
  }
608
644
  }
609
645
 
646
+
610
647
  // Game Merge Functions
611
648
  async function openGameMergeModal() {
612
649
  const selectedCheckboxes = document.querySelectorAll('.game-checkbox:checked');