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,100 @@
1
+ // Shared regex patterns for search and deletion functionality
2
+ // This module provides preset regex patterns and helper functions
3
+
4
+ /**
5
+ * Preset regex patterns with descriptions
6
+ * Each pattern includes:
7
+ * - value: unique identifier
8
+ * - label: user-friendly description
9
+ * - pattern: the actual regex pattern
10
+ */
11
+ export const PRESET_PATTERNS = {
12
+ 'lines_over_50': {
13
+ label: 'Lines over 50 characters',
14
+ pattern: '.{51,}'
15
+ },
16
+ 'lines_over_100': {
17
+ label: 'Lines over 100 characters',
18
+ pattern: '.{101,}'
19
+ },
20
+ 'non_japanese': {
21
+ label: 'Non-Japanese text',
22
+ pattern: '^[^\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]*$'
23
+ },
24
+ 'ascii_only': {
25
+ label: 'ASCII-only lines',
26
+ pattern: '^[\x00-\x7F]*$'
27
+ },
28
+ 'empty_lines': {
29
+ label: 'Empty or whitespace-only lines',
30
+ pattern: '^\s*$'
31
+ },
32
+ 'numbers_only': {
33
+ label: 'Lines with numbers only',
34
+ pattern: '^\d+$'
35
+ },
36
+ 'single_char': {
37
+ label: 'Single character lines',
38
+ pattern: '^.{1}$'
39
+ },
40
+ 'repeated_chars': {
41
+ label: 'Lines with repeated characters (3+ times)',
42
+ pattern: '(.)\\1{2,}'
43
+ },
44
+ 'everything': {
45
+ label: 'Everything',
46
+ pattern: '.*'
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Get pattern by key
52
+ * @param {string} key - Pattern key
53
+ * @returns {string|null} - Pattern string or null if not found
54
+ */
55
+ export function getPattern(key) {
56
+ return PRESET_PATTERNS[key]?.pattern || null;
57
+ }
58
+
59
+ /**
60
+ * Get all pattern options for dropdown
61
+ * @returns {Array} - Array of {value, label} objects
62
+ */
63
+ export function getPatternOptions() {
64
+ return Object.entries(PRESET_PATTERNS).map(([key, data]) => ({
65
+ value: key,
66
+ label: data.label
67
+ }));
68
+ }
69
+
70
+ /**
71
+ * Validate regex pattern
72
+ * @param {string} pattern - Regex pattern to validate
73
+ * @returns {Object} - {valid: boolean, error: string|null}
74
+ */
75
+ export function validateRegex(pattern) {
76
+ try {
77
+ new RegExp(pattern);
78
+ return { valid: true, error: null };
79
+ } catch (e) {
80
+ return { valid: false, error: e.message };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Apply regex pattern to text
86
+ * @param {string} text - Text to test
87
+ * @param {string} pattern - Regex pattern
88
+ * @param {boolean} caseSensitive - Case sensitivity flag
89
+ * @returns {boolean} - True if pattern matches
90
+ */
91
+ export function testPattern(text, pattern, caseSensitive = false) {
92
+ try {
93
+ const flags = caseSensitive ? 'g' : 'gi';
94
+ const regex = new RegExp(pattern, flags);
95
+ return regex.test(text);
96
+ } catch (e) {
97
+ console.error('Regex test error:', e);
98
+ return false;
99
+ }
100
+ }
@@ -3,6 +3,8 @@ class SentenceSearchApp {
3
3
  this.searchInput = document.getElementById('searchInput');
4
4
  this.gameFilter = document.getElementById('gameFilter');
5
5
  this.sortFilter = document.getElementById('sortFilter');
6
+ this.fromDateFilter = document.getElementById('searchFromDate');
7
+ this.toDateFilter = document.getElementById('searchToDate');
6
8
  this.searchResults = document.getElementById('searchResults');
7
9
  this.loadingIndicator = document.getElementById('loadingIndicator');
8
10
  this.noResults = document.getElementById('noResults');
@@ -10,10 +12,27 @@ class SentenceSearchApp {
10
12
  this.errorMessage = document.getElementById('errorMessage');
11
13
  this.searchStats = document.getElementById('searchStats');
12
14
  this.searchTime = document.getElementById('searchTime');
13
- this.regexCheckbox = document.getElementById('regexCheckbox');
15
+
16
+ // Regex component elements
17
+ this.regexPresetSelect = document.querySelector('.regex-preset-select');
18
+ this.regexCustomInput = document.querySelector('.regex-custom-input');
19
+ this.regexCaseCheckbox = document.querySelector('.regex-case-checkbox');
20
+ this.regexModeCheckbox = document.querySelector('.regex-mode-checkbox');
21
+
22
+ // Duplicate detection elements
23
+ this.toggleDuplicateBtn = document.getElementById('toggleDuplicateDetection');
24
+ this.duplicateSection = document.getElementById('duplicateDetectionSection');
25
+ this.duplicateTimeWindow = document.getElementById('duplicateTimeWindow');
26
+ this.duplicateIgnoreTimeWindow = document.getElementById('duplicateIgnoreTimeWindow');
27
+ this.duplicateCaseSensitive = document.getElementById('duplicateCaseSensitive');
28
+ this.searchDuplicatesBtn = document.getElementById('searchDuplicatesBtn');
29
+ this.duplicateTimeWindowGroup = document.getElementById('duplicateTimeWindowGroup');
30
+
14
31
  this.deleteLinesBtn = document.getElementById('deleteLinesBtn');
15
32
  this.selectAllBtn = document.getElementById('selectAllBtn');
16
33
  this.pageSizeFilter = document.getElementById('pageSizeFilter');
34
+ this.toggleAdvancedBtn = document.getElementById('toggleAdvancedSearch');
35
+ this.advancedSearchSection = document.getElementById('advancedSearchSection');
17
36
 
18
37
  this.currentPage = 1;
19
38
  this.pageSize = 20;
@@ -21,6 +40,7 @@ class SentenceSearchApp {
21
40
  this.currentQuery = '';
22
41
  this.totalResults = 0;
23
42
  this.currentUseRegex = false;
43
+ this.isDuplicateSearch = false;
24
44
  this.initialize();
25
45
  }
26
46
 
@@ -56,6 +76,8 @@ class SentenceSearchApp {
56
76
  this.gameFilter.addEventListener('change', () => this.performSearch());
57
77
  this.sortFilter.addEventListener('change', () => this.performSearch());
58
78
 
79
+ // Date range filters do NOT auto-trigger search - user must click Search button
80
+
59
81
  if (this.pageSizeFilter) {
60
82
  this.pageSizeFilter.addEventListener('change', () => {
61
83
  this.pageSize = parseInt(this.pageSizeFilter.value);
@@ -76,8 +98,24 @@ class SentenceSearchApp {
76
98
  this.performSearch();
77
99
  });
78
100
 
79
- if (this.regexCheckbox) {
80
- this.regexCheckbox.addEventListener('change', () => {
101
+ // Regex component event listeners
102
+ if (this.regexCustomInput) {
103
+ this.regexCustomInput.addEventListener('input', () => {
104
+ clearTimeout(this.searchTimeout);
105
+ this.searchTimeout = setTimeout(() => {
106
+ this.performSearch();
107
+ }, 300);
108
+ });
109
+ }
110
+
111
+ if (this.regexModeCheckbox) {
112
+ this.regexModeCheckbox.addEventListener('change', () => {
113
+ this.performSearch();
114
+ });
115
+ }
116
+
117
+ if (this.regexCaseCheckbox) {
118
+ this.regexCaseCheckbox.addEventListener('change', () => {
81
119
  this.performSearch();
82
120
  });
83
121
  }
@@ -93,6 +131,53 @@ class SentenceSearchApp {
93
131
  this.toggleSelectAll();
94
132
  });
95
133
  }
134
+
135
+ if (this.toggleAdvancedBtn) {
136
+ this.toggleAdvancedBtn.addEventListener('click', () => {
137
+ this.toggleAdvancedSearch();
138
+ });
139
+ }
140
+
141
+ // Duplicate detection event listeners
142
+ if (this.toggleDuplicateBtn) {
143
+ this.toggleDuplicateBtn.addEventListener('click', () => {
144
+ this.toggleDuplicateDetection();
145
+ });
146
+ }
147
+
148
+ if (this.duplicateIgnoreTimeWindow) {
149
+ this.duplicateIgnoreTimeWindow.addEventListener('change', () => {
150
+ this.toggleDuplicateTimeWindow();
151
+ });
152
+ }
153
+
154
+ if (this.searchDuplicatesBtn) {
155
+ this.searchDuplicatesBtn.addEventListener('click', () => {
156
+ this.searchForDuplicates();
157
+ });
158
+ }
159
+
160
+ // Manual search button for date filtering
161
+ const manualSearchBtn = document.getElementById('manualSearchBtn');
162
+ if (manualSearchBtn) {
163
+ manualSearchBtn.addEventListener('click', () => {
164
+ this.performSearch();
165
+ });
166
+ }
167
+ }
168
+
169
+ toggleAdvancedSearch() {
170
+ if (!this.advancedSearchSection || !this.toggleAdvancedBtn) return;
171
+
172
+ const isHidden = this.advancedSearchSection.style.display === 'none';
173
+
174
+ if (isHidden) {
175
+ this.advancedSearchSection.style.display = 'block';
176
+ this.toggleAdvancedBtn.innerHTML = '<span id="toggleIcon">▲</span> Hide Advanced Search';
177
+ } else {
178
+ this.advancedSearchSection.style.display = 'none';
179
+ this.toggleAdvancedBtn.innerHTML = '<span id="toggleIcon">▼</span> Show Advanced Search';
180
+ }
96
181
  }
97
182
 
98
183
  async loadGamesList() {
@@ -120,25 +205,36 @@ class SentenceSearchApp {
120
205
  const query = this.searchInput.value.trim();
121
206
  const gameFilter = this.gameFilter.value;
122
207
  const sortBy = this.sortFilter.value;
123
- const useRegex = this.regexCheckbox && this.regexCheckbox.checked;
124
-
125
- if (query !== this.currentQuery || useRegex !== this.currentUseRegex) {
126
- this.currentPage = 1;
208
+ const fromDate = this.fromDateFilter ? this.fromDateFilter.value : '';
209
+ const toDate = this.toDateFilter ? this.toDateFilter.value : '';
210
+
211
+ // Get regex settings from component
212
+ const customRegex = this.regexCustomInput ? this.regexCustomInput.value.trim() : '';
213
+ const useRegex = this.regexModeCheckbox ? this.regexModeCheckbox.checked : false;
214
+ const caseSensitive = this.regexCaseCheckbox ? this.regexCaseCheckbox.checked : false;
215
+
216
+ // Use custom regex if provided and regex mode is enabled, otherwise use search query
217
+ // If search is empty, default to .* regex pattern
218
+ let searchPattern = (useRegex && customRegex) ? customRegex : query;
219
+ let effectiveUseRegex = useRegex;
220
+
221
+ if (!searchPattern && !query) {
222
+ searchPattern = '.*';
223
+ effectiveUseRegex = true; // Force regex mode for .* pattern
127
224
  }
128
- this.currentQuery = query;
129
- this.currentUseRegex = useRegex;
130
225
 
131
- if (!query) {
132
- this.showEmptyState();
133
- return;
226
+ if (searchPattern !== this.currentQuery || effectiveUseRegex !== this.currentUseRegex) {
227
+ this.currentPage = 1;
134
228
  }
229
+ this.currentQuery = searchPattern;
230
+ this.currentUseRegex = effectiveUseRegex;
135
231
 
136
232
  this.showLoadingState();
137
233
  const startTime = Date.now();
138
234
 
139
235
  try {
140
236
  const params = new URLSearchParams({
141
- q: query,
237
+ q: searchPattern,
142
238
  page: this.currentPage,
143
239
  page_size: this.pageSize,
144
240
  sort: sortBy
@@ -147,9 +243,18 @@ class SentenceSearchApp {
147
243
  if (gameFilter) {
148
244
  params.append('game', gameFilter);
149
245
  }
150
- if (useRegex) {
246
+ if (fromDate) {
247
+ params.append('from_date', fromDate);
248
+ }
249
+ if (toDate) {
250
+ params.append('to_date', toDate);
251
+ }
252
+ if (effectiveUseRegex) {
151
253
  params.append('use_regex', 'true');
152
254
  }
255
+ if (caseSensitive) {
256
+ params.append('case_sensitive', 'true');
257
+ }
153
258
 
154
259
  const response = await fetch(`/api/search-sentences?${params}`);
155
260
  const data = await response.json();
@@ -248,12 +353,15 @@ class SentenceSearchApp {
248
353
  highlightSearchTerms(text, query) {
249
354
  if (!query) return escapeHtml(text);
250
355
 
251
- const useRegex = this.regexCheckbox && this.regexCheckbox.checked;
356
+ const useRegex = this.regexModeCheckbox ? this.regexModeCheckbox.checked : false;
357
+ const customRegex = this.regexCustomInput ? this.regexCustomInput.value.trim() : '';
358
+ const caseSensitive = this.regexCaseCheckbox ? this.regexCaseCheckbox.checked : false;
252
359
  const escapedText = escapeHtml(text);
253
360
 
254
- if (useRegex) {
361
+ if (useRegex && customRegex) {
255
362
  try {
256
- const pattern = new RegExp(query, 'gi');
363
+ const flags = caseSensitive ? 'g' : 'gi';
364
+ const pattern = new RegExp(customRegex, flags);
257
365
  return escapedText.replace(pattern, '<span class="search-highlight">$&</span>');
258
366
  } catch (e) {
259
367
  return escapedText;
@@ -262,7 +370,8 @@ class SentenceSearchApp {
262
370
  const searchTerms = query.split(' ').filter(term => term.length > 0);
263
371
  let result = escapedText;
264
372
  searchTerms.forEach(term => {
265
- const regex = new RegExp(`(${escapeRegex(term)})`, 'gi');
373
+ const flags = caseSensitive ? 'g' : 'gi';
374
+ const regex = new RegExp(`(${escapeRegex(term)})`, flags);
266
375
  result = result.replace(regex, '<span class="search-highlight">$1</span>');
267
376
  });
268
377
  return result;
@@ -436,6 +545,94 @@ class SentenceSearchApp {
436
545
  }
437
546
  }
438
547
 
548
+ toggleDuplicateDetection() {
549
+ if (!this.duplicateSection || !this.toggleDuplicateBtn) return;
550
+
551
+ const isHidden = this.duplicateSection.style.display === 'none';
552
+ const icon = document.getElementById('duplicateToggleIcon');
553
+
554
+ if (isHidden) {
555
+ this.duplicateSection.style.display = 'block';
556
+ if (icon) icon.textContent = '▲';
557
+ } else {
558
+ this.duplicateSection.style.display = 'none';
559
+ if (icon) icon.textContent = '▼';
560
+ }
561
+ }
562
+
563
+ toggleDuplicateTimeWindow() {
564
+ if (!this.duplicateIgnoreTimeWindow || !this.duplicateTimeWindowGroup) return;
565
+
566
+ const isIgnored = this.duplicateIgnoreTimeWindow.checked;
567
+
568
+ if (isIgnored) {
569
+ this.duplicateTimeWindowGroup.style.opacity = '0.5';
570
+ this.duplicateTimeWindowGroup.style.pointerEvents = 'none';
571
+ if (this.duplicateTimeWindow) {
572
+ this.duplicateTimeWindow.disabled = true;
573
+ }
574
+ } else {
575
+ this.duplicateTimeWindowGroup.style.opacity = '1';
576
+ this.duplicateTimeWindowGroup.style.pointerEvents = 'auto';
577
+ if (this.duplicateTimeWindow) {
578
+ this.duplicateTimeWindow.disabled = false;
579
+ }
580
+ }
581
+ }
582
+
583
+ async searchForDuplicates() {
584
+ const gameFilter = this.gameFilter.value;
585
+ const timeWindow = parseInt(this.duplicateTimeWindow.value);
586
+ const ignoreTimeWindow = this.duplicateIgnoreTimeWindow.checked;
587
+ const caseSensitive = this.duplicateCaseSensitive.checked;
588
+
589
+ // Validate input
590
+ if (!ignoreTimeWindow && (isNaN(timeWindow) || timeWindow < 1)) {
591
+ this.showErrorState('Time window must be at least 1 minute');
592
+ return;
593
+ }
594
+
595
+ this.showLoadingState();
596
+ this.isDuplicateSearch = true;
597
+ const startTime = Date.now();
598
+
599
+ try {
600
+ const requestData = {
601
+ game: gameFilter,
602
+ time_window_minutes: timeWindow,
603
+ ignore_time_window: ignoreTimeWindow,
604
+ case_sensitive: caseSensitive
605
+ };
606
+
607
+ const response = await fetch('/api/search-duplicates', {
608
+ method: 'POST',
609
+ headers: { 'Content-Type': 'application/json' },
610
+ body: JSON.stringify(requestData)
611
+ });
612
+
613
+ const data = await response.json();
614
+ const searchTime = Date.now() - startTime;
615
+
616
+ if (!response.ok) {
617
+ throw new Error(data.error || 'Duplicate search failed');
618
+ }
619
+
620
+ // Display results using existing display method
621
+ this.displayResults(data, searchTime);
622
+
623
+ // Update stats text to indicate duplicate search
624
+ if (data.total > 0) {
625
+ const modeText = ignoreTimeWindow ? 'across entire game' : `within ${timeWindow} minute window`;
626
+ const gameText = gameFilter ? ` in ${gameFilter}` : '';
627
+ this.searchStats.textContent = `Found ${data.total.toLocaleString()} duplicate sentences ${modeText}${gameText}`;
628
+ }
629
+
630
+ } catch (error) {
631
+ this.showErrorState(error.message);
632
+ this.isDuplicateSearch = false;
633
+ }
634
+ }
635
+
439
636
  showMessage(title, message) {
440
637
  document.getElementById('messageModalTitle').textContent = title;
441
638
  document.getElementById('messageModalText').textContent = message;
@@ -517,6 +517,62 @@ function escapeRegex(string) {
517
517
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
518
518
  }
519
519
 
520
+ function safeJoinArray(arr, separator = ', ') {
521
+ /**
522
+ * Safely join an array with proper type checking and fallbacks.
523
+ * Handles various data types that might be returned from API responses.
524
+ *
525
+ * @param {*} arr - The value to join (should be an array, but handles other types)
526
+ * @param {string} separator - The separator to use for joining
527
+ * @returns {string} - The joined string or appropriate fallback
528
+ */
529
+ if (!arr) {
530
+ return '';
531
+ }
532
+
533
+ if (Array.isArray(arr)) {
534
+ return arr.join(separator);
535
+ }
536
+
537
+ if (typeof arr === 'string') {
538
+ return arr;
539
+ }
540
+
541
+ // Handle other types by converting to string
542
+ return String(arr);
543
+ }
544
+
545
+ function logApiResponse(operation, response, result) {
546
+ /**
547
+ * Log API response details for debugging purposes.
548
+ *
549
+ * @param {string} operation - The operation being performed
550
+ * @param {Response} response - The fetch response object
551
+ * @param {*} result - The parsed JSON result
552
+ */
553
+ console.group(`🔍 API Response Debug: ${operation}`);
554
+ console.log('Response status:', response.status, response.statusText);
555
+ console.log('Response OK:', response.ok);
556
+ console.log('Result object:', result);
557
+
558
+ // Print FULL API response as formatted JSON
559
+ console.log('%c📋 FULL API RESPONSE (JSON):', 'color: #00ff00; font-weight: bold; font-size: 14px;');
560
+ console.log(JSON.stringify(result, null, 2));
561
+
562
+ if (result && typeof result === 'object') {
563
+ Object.keys(result).forEach(key => {
564
+ const value = result[key];
565
+ console.log(`${key}:`, {
566
+ value,
567
+ type: typeof value,
568
+ isArray: Array.isArray(value),
569
+ length: Array.isArray(value) ? value.length : 'N/A'
570
+ });
571
+ });
572
+ }
573
+ console.groupEnd();
574
+ }
575
+
520
576
  // Screenshot functionality
521
577
  function initializeScreenshotButton() {
522
578
  const screenshotButton = document.getElementById('screenshotToggle');
@@ -525,62 +581,160 @@ function initializeScreenshotButton() {
525
581
  return; // Screenshot button not available on this page
526
582
  }
527
583
 
528
- screenshotButton.addEventListener('click', takeScreenshot);
584
+ screenshotButton.addEventListener('click', exportPageToPDF);
585
+ }
586
+
587
+ // Lazy-load libraries
588
+ let html2canvasLoaded = false;
589
+ let html2canvasLoading = false;
590
+ let jsPDFLoaded = false;
591
+ let jsPDFLoading = false;
592
+
593
+ // Lazy-load html2canvas
594
+ async function loadHtml2Canvas() {
595
+ if (html2canvasLoaded) return true;
596
+ if (html2canvasLoading) {
597
+ return new Promise(resolve => {
598
+ const check = setInterval(() => {
599
+ if (html2canvasLoaded) {
600
+ clearInterval(check);
601
+ resolve(true);
602
+ }
603
+ }, 100);
604
+ });
605
+ }
606
+
607
+ html2canvasLoading = true;
608
+ return new Promise((resolve, reject) => {
609
+ const script = document.createElement('script');
610
+ script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
611
+ script.onload = () => {
612
+ html2canvasLoaded = true;
613
+ html2canvasLoading = false;
614
+ resolve(true);
615
+ };
616
+ script.onerror = () => {
617
+ html2canvasLoading = false;
618
+ reject(new Error('Failed to load html2canvas'));
619
+ };
620
+ document.head.appendChild(script);
621
+ });
529
622
  }
530
623
 
531
- async function takeScreenshot() {
624
+ // Lazy-load jsPDF
625
+ async function loadJsPDF() {
626
+ if (jsPDFLoaded) return true;
627
+ if (jsPDFLoading) {
628
+ return new Promise(resolve => {
629
+ const check = setInterval(() => {
630
+ if (jsPDFLoaded) {
631
+ clearInterval(check);
632
+ resolve(true);
633
+ }
634
+ }, 100);
635
+ });
636
+ }
637
+
638
+ jsPDFLoading = true;
639
+ return new Promise((resolve, reject) => {
640
+ const script = document.createElement('script');
641
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
642
+ script.onload = () => {
643
+ jsPDFLoaded = true;
644
+ jsPDFLoading = false;
645
+ resolve(true);
646
+ };
647
+ script.onerror = () => {
648
+ jsPDFLoading = false;
649
+ reject(new Error('Failed to load jsPDF'));
650
+ };
651
+ document.head.appendChild(script);
652
+ });
653
+ }
654
+
655
+ // Capture page and export as PDF
656
+ async function exportPageToPDF() {
532
657
  try {
533
- // Check if html2canvas is available
658
+ console.log('Starting PDF export...');
659
+
660
+ // Load libraries
534
661
  if (typeof html2canvas === 'undefined') {
535
- console.error('html2canvas library not loaded');
536
- return;
662
+ console.log('Loading html2canvas...');
663
+ await loadHtml2Canvas();
537
664
  }
538
-
539
- // Generate timestamp for filename
665
+ if (typeof window.jspdf === 'undefined') {
666
+ console.log('Loading jsPDF...');
667
+ await loadJsPDF();
668
+ }
669
+
670
+ const { jsPDF } = window.jspdf;
671
+
672
+ // Create timestamped filename
540
673
  const now = new Date();
541
- const timestamp = now.getFullYear() + '-' +
542
- String(now.getMonth() + 1).padStart(2, '0') + '-' +
543
- String(now.getDate()).padStart(2, '0') + '_' +
544
- String(now.getHours()).padStart(2, '0') + '-' +
545
- String(now.getMinutes()).padStart(2, '0') + '-' +
546
- String(now.getSeconds()).padStart(2, '0');
547
-
548
- const filename = `screenshot_${timestamp}.png`;
549
-
550
- // Capture the entire page
674
+ const timestamp =
675
+ now.getFullYear() + '-' +
676
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
677
+ String(now.getDate()).padStart(2, '0') + '_' +
678
+ String(now.getHours()).padStart(2, '0') + '-' +
679
+ String(now.getMinutes()).padStart(2, '0') + '-' +
680
+ String(now.getSeconds()).padStart(2, '0');
681
+ const filename = `GSM_STATS_${timestamp}.pdf`;
682
+
683
+ console.log('Capturing page screenshot...');
684
+ // Take a screenshot of the full page with high quality for sharp text
551
685
  const canvas = await html2canvas(document.body, {
552
686
  useCORS: true,
553
687
  allowTaint: true,
554
- scale: 1,
688
+ scale: 2.5, // Higher scale for sharper text (2.5x resolution)
555
689
  scrollX: 0,
556
690
  scrollY: 0,
557
691
  width: document.body.scrollWidth,
558
- height: document.body.scrollHeight
692
+ height: document.body.scrollHeight,
693
+ logging: false,
694
+ imageTimeout: 0,
695
+ removeContainer: true
559
696
  });
560
-
561
- // Convert canvas to blob
562
- canvas.toBlob(function(blob) {
563
- // Create download link
564
- const link = document.createElement('a');
565
- link.download = filename;
566
- link.href = URL.createObjectURL(blob);
567
-
568
- // Trigger download
569
- document.body.appendChild(link);
570
- link.click();
571
- document.body.removeChild(link);
572
-
573
- // Clean up the URL object after a short delay to avoid race condition
574
- setTimeout(function() {
575
- URL.revokeObjectURL(link.href);
576
- }, 100);
577
- }, 'image/png');
578
-
697
+
698
+ console.log('Converting to image...');
699
+ // Use JPEG with high quality for better file size vs quality balance
700
+ const imgData = canvas.toDataURL('image/jpeg', 0.80);
701
+
702
+ console.log('Creating PDF...');
703
+ // PDF setup (A4)
704
+ const pdf = new jsPDF('p', 'pt', 'a4');
705
+ const pageWidth = pdf.internal.pageSize.getWidth();
706
+ const pageHeight = pdf.internal.pageSize.getHeight();
707
+
708
+ const imgWidth = pageWidth;
709
+ const imgHeight = (canvas.height * imgWidth) / canvas.width;
710
+
711
+ let heightLeft = imgHeight;
712
+ let position = 0;
713
+
714
+ // Add first page with SLOW compression for better quality
715
+ pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight, undefined, 'SLOW');
716
+ heightLeft -= pageHeight;
717
+
718
+ // Add more pages if needed
719
+ while (heightLeft > 0) {
720
+ position = heightLeft - imgHeight;
721
+ pdf.addPage();
722
+ pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight, undefined, 'SLOW');
723
+ heightLeft -= pageHeight;
724
+ }
725
+
726
+ console.log('Saving PDF...');
727
+ // Download
728
+ pdf.save(filename);
729
+ console.log('PDF export complete!');
730
+
579
731
  } catch (error) {
580
- console.error('Screenshot failed:', error);
732
+ console.error('PDF export failed:', error);
733
+ alert('Failed to export PDF: ' + error.message);
581
734
  }
582
735
  }
583
736
 
737
+
584
738
  // Initialize shared functionality when DOM loads
585
739
  document.addEventListener('DOMContentLoaded', function() {
586
740
  // Initialize theme toggle