hlsv 1.0.0 → 2.0.0

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -3
  3. data/README.md +36 -29
  4. data/lib/hlsv/analysis_registry.rb +47 -0
  5. data/lib/hlsv/analysis_runner.rb +193 -0
  6. data/lib/hlsv/config_manager.rb +125 -0
  7. data/lib/hlsv/html2word.rb +18 -18
  8. data/lib/hlsv/path_guard.rb +32 -0
  9. data/lib/hlsv/sdtm_validation/dataset.rb +169 -0
  10. data/lib/hlsv/sdtm_validation/define.rb +142 -0
  11. data/lib/hlsv/sdtm_validation/report.rb +357 -0
  12. data/lib/hlsv/sdtm_validation.rb +390 -0
  13. data/lib/hlsv/url_helper.rb +28 -0
  14. data/lib/hlsv/version.rb +1 -1
  15. data/lib/hlsv/web_app.rb +264 -418
  16. data/lib/hlsv.rb +12 -5
  17. data/public/css/accessibility/accessibility.css +33 -0
  18. data/public/css/base/layout.css +33 -0
  19. data/public/css/base/reset.css +23 -0
  20. data/public/css/base/typography.css +31 -0
  21. data/public/css/components/buttons.css +142 -0
  22. data/public/css/components/file-tree.css +107 -0
  23. data/public/css/components/footer.css +43 -0
  24. data/public/css/components/forms.css +56 -0
  25. data/public/css/components/header.css +52 -0
  26. data/public/css/components/status.css +56 -0
  27. data/public/css/features/csv-table.css +204 -0
  28. data/public/css/features/file-browser.css +208 -0
  29. data/public/css/responsive/responsive.css +133 -0
  30. data/public/css/styles.css +25 -0
  31. data/public/css/styles_csv.css +23 -0
  32. data/public/favicon.ico +0 -0
  33. data/public/js/analysis.js +201 -0
  34. data/public/js/app.js +63 -0
  35. data/public/js/browser.js +172 -0
  36. data/public/js/config.js +214 -0
  37. data/public/js/results.js +240 -0
  38. data/public/js/utils.js +57 -0
  39. data/views/csv_view.erb +11 -12
  40. data/views/index.erb +70 -19
  41. data/views/{report_template.erb → report.erb} +203 -188
  42. metadata +39 -41
  43. data/lib/hlsv/find_keys.rb +0 -979
  44. data/lib/hlsv/mon_script.rb +0 -169
  45. data/public/app.js +0 -569
  46. data/public/styles.css +0 -586
  47. data/public/styles_csv.css +0 -448
@@ -0,0 +1,201 @@
1
+ /*
2
+ Copyright (c) 2026 AdClin
3
+ Licensed under the GNU Affero General Public License v3.0 or later.
4
+ See the LICENSE file for details.
5
+ */
6
+
7
+ import { configSave } from './config.js';
8
+ import { refreshResults } from './results.js';
9
+ import { formatDuration } from './utils.js';
10
+
11
+ export async function startAnalysis() {
12
+ const statusDiv = document.getElementById('analysis-status');
13
+ const btn = document.getElementById('btn-process');
14
+
15
+ btn.disabled = true;
16
+ statusDiv.innerHTML = '<div class="status processing">⏳ Processing in progress...</div>';
17
+
18
+ // Save the configuration before starting the process
19
+ const configSaved = await configSave();
20
+
21
+ if (!configSaved) {
22
+ statusDiv.innerHTML = `
23
+ <div class="status error">
24
+ <h3>❌ Configuration Error</h3>
25
+ <p>The configuration could not be saved. Please correct the errors before starting the analysis.</p>
26
+ <p style="margin-top: 10px; color: #666; font-size: 0.9em;">
27
+ Check the error message in the Configuration section above.
28
+ </p>
29
+ </div>
30
+ `;
31
+ btn.disabled = false;
32
+ return;
33
+ }
34
+
35
+ try {
36
+ // Start timing just before the HTTP request
37
+ const startTime = performance.now();
38
+
39
+ const response = await fetch('/proceed', {
40
+ method: 'POST'
41
+ });
42
+
43
+ const data = await response.json();
44
+
45
+ // Calculate total HTTP request duration
46
+ const elapsedTime = performance.now() - startTime;
47
+ const formattedDuration = formatDuration(elapsedTime);
48
+
49
+ if (data.success) {
50
+ let successMessage = `
51
+ <h3>✅ Analysis completed successfully!</h3>
52
+ <p style="margin-top: 10px;">
53
+ ⏱️ Processing time: <strong>${formattedDuration}</strong>
54
+ </p>
55
+ `;
56
+
57
+ // Display completed steps
58
+ if (data.result.steps) {
59
+ successMessage += '<div style="margin: 20px 0; text-align: left;">';
60
+ successMessage += '<h4>📋 Completed Steps:</h4>';
61
+ successMessage += '<ol>';
62
+
63
+ data.result.steps.forEach(step => {
64
+ let icon = '✅';
65
+ let color = '#28a745';
66
+
67
+ if (step.status === 'in_progress') {
68
+ icon = '⏳';
69
+ color = '#ffc107';
70
+ } else if (step.status === 'error') {
71
+ icon = '❌';
72
+ color = '#dc3545';
73
+ }
74
+
75
+ successMessage += `
76
+ <li style="color: ${color};">
77
+ ${icon} <strong>${step.name}:</strong>
78
+ ${step.message ? `<span style="color: #666; font-size: 0.9em;">${step.message}</span>` : ''}
79
+ </li>
80
+ `;
81
+ });
82
+
83
+ successMessage += '</ol></div>';
84
+ }
85
+
86
+ // Display report generation warnings, if any (analysis itself
87
+ // succeeded, but one or more of html/docx/excel failed to generate)
88
+ if (data.result.report_warnings && data.result.report_warnings.length > 0) {
89
+ successMessage += '<div style="margin: 20px 0; text-align: left;">';
90
+ successMessage += '<div class="status" style="background: #fff3cd; color: #856404; padding: 10px; border-radius: 4px;">';
91
+ successMessage += '<strong>⚠️ Some reports could not be generated:</strong>';
92
+ successMessage += '<ul style="margin: 5px 0 0; padding-left: 20px;">';
93
+ data.result.report_warnings.forEach(warning => {
94
+ successMessage += `<li>${warning}</li>`;
95
+ });
96
+ successMessage += '</ul></div></div>';
97
+ }
98
+
99
+ // Display generated files
100
+ // Note: nothing is written to disk yet at this point (html/docx/excel
101
+ // are generated on demand). The report link below triggers that
102
+ // generation the first time it's opened.
103
+ if (data.result.study_name || data.result.output_directory) {
104
+ successMessage += '<div style="margin: 20px 0; text-align: left;">';
105
+ successMessage += '<h4>📄 Output Files:</h4>';
106
+ successMessage += '<ul style="margin: 0; padding-left: 20px;">';
107
+
108
+ if (data.result.study_name) {
109
+ const reportUrl = '/report/' + encodeURIComponent(data.result.study_name) + '/html';
110
+ successMessage += `
111
+ <li>
112
+ <strong>Report:</strong>
113
+ <a href="${reportUrl}" target="_blank" rel="noopener noreferrer">
114
+ View HTML report
115
+ </a>
116
+ </li>
117
+ `;
118
+ }
119
+ if (data.result.output_directory) {
120
+ successMessage += `<li><strong>Output Directory:</strong> ${data.result.output_directory}</li>`;
121
+ }
122
+
123
+ successMessage += '</ul></div>';
124
+ }
125
+
126
+ // Display statistics
127
+ if (data.result.analyzed_dataset_count || data.result.invalid_dataset_summary) {
128
+ successMessage += '<div style="margin: 20px 0; text-align: left;">';
129
+ successMessage += '<h4>📊 Dataset Statistics:</h4>';
130
+ successMessage += '<ul style="margin: 0; padding-left: 20px;">';
131
+
132
+ if (data.result.analyzed_dataset_count) {
133
+ successMessage += `<li><strong>Analyzed:</strong> ${data.result.analyzed_dataset_count}</li>`;
134
+ }
135
+
136
+ const invalidDatasets = data.result.invalid_dataset_summary;
137
+ if (invalidDatasets) {
138
+ if (invalidDatasets.invalid_dataset) {
139
+ successMessage += `<li><strong>With at least one issue:</strong> ${invalidDatasets.invalid_dataset}</li>`;
140
+ }
141
+ if (invalidDatasets.invalid_ascii) {
142
+ successMessage += `<li><strong>ASCII issue:</strong> ${invalidDatasets.invalid_ascii}</li>`;
143
+ }
144
+ if (invalidDatasets.invalid_define) {
145
+ successMessage += `<li><strong>Invalid keys in define.xml:</strong> ${invalidDatasets.invalid_define}</li>`;
146
+ } else {
147
+ successMessage += `<li>✅ <strong>All keys in define.xml are valid.</strong></li>`;
148
+ }
149
+ if (invalidDatasets.invalid_data) {
150
+ successMessage += `<li><strong>Minimum keys not found:</strong> ${invalidDatasets.invalid_data}</li>`;
151
+ }
152
+ }
153
+
154
+ successMessage += '</ul></div>';
155
+ }
156
+
157
+ statusDiv.innerHTML = `<div class="status">${successMessage}</div>`;
158
+
159
+ // Auto-refresh results if already loaded
160
+ const resultsList = document.getElementById('results-list');
161
+ if (resultsList.style.display !== 'none') {
162
+ refreshResults();
163
+ }
164
+
165
+ } else {
166
+ // Display validation errors
167
+ let errorMessage = `<h3>❌ ${data.error}</h3>`;
168
+
169
+ if (data.details && data.details.length > 0) {
170
+ errorMessage += '<div style="margin: 20px 0; text-align: left;">';
171
+ errorMessage += '<h4>Validation Errors:</h4>';
172
+ errorMessage += '<ul style="margin-left: 20px; color: #721c24;">';
173
+ data.details.forEach(errorItem => {
174
+ errorMessage += `<li style="margin: 5px 0;">${errorItem}</li>`;
175
+ });
176
+ errorMessage += '</ul></div>';
177
+ errorMessage += '<p style="margin-top: 15px;">⚠️ Please correct these errors before restarting the analysis</p>';
178
+ } else {
179
+ errorMessage += `<p style="margin-top: 15px;">${data.error}</p>`;
180
+ }
181
+
182
+ statusDiv.innerHTML = `<div class="status error">${errorMessage}</div>`;
183
+ }
184
+ } catch (error) {
185
+ statusDiv.innerHTML = `
186
+ <div class="status error">
187
+ <h3>❌ Connection Error</h3>
188
+ <p>${error.message}</p>
189
+ <p style="margin-top: 10px; color: #666; font-size: 0.9em;">
190
+ Please check that the server is running correctly (ruby app.rb)
191
+ </p>
192
+ </div>
193
+ `;
194
+ } finally {
195
+ btn.disabled = false;
196
+ }
197
+ }
198
+
199
+ // Results Management
200
+
201
+ // Load results for the first time
data/public/js/app.js ADDED
@@ -0,0 +1,63 @@
1
+ /*
2
+ Copyright (c) 2026 AdClin
3
+ Licensed under the GNU Affero General Public License v3.0 or later.
4
+ See the LICENSE file for details.
5
+ */
6
+
7
+ import {
8
+ configSave,
9
+ fullConfigViewer,
10
+ clearFields,
11
+ loadDefaultConfig,
12
+ loadExistingConfig,
13
+ applyExistingConfig
14
+ } from './config.js';
15
+
16
+ import { startAnalysis } from './analysis.js';
17
+
18
+ import {
19
+ loadResults,
20
+ refreshResults,
21
+ toggleFolder,
22
+ download,
23
+ openFile,
24
+ viewHtmlReport,
25
+ downloadFolderAsZip,
26
+ downloadExcelStudy,
27
+ downloadHtmlAsWord
28
+ } from './results.js';
29
+
30
+ import {
31
+ openBrowser,
32
+ closeBrowser,
33
+ navigateBrowse,
34
+ browseUp,
35
+ selectBrowseFile,
36
+ confirmBrowseSelection
37
+ } from './browser.js';
38
+
39
+ Object.assign(window, {
40
+ configSave,
41
+ fullConfigViewer,
42
+ clearFields,
43
+ loadDefaultConfig,
44
+ loadExistingConfig,
45
+ applyExistingConfig,
46
+ startAnalysis,
47
+ loadResults,
48
+ refreshResults,
49
+ toggleFolder,
50
+ download,
51
+ openFile,
52
+ viewHtmlReport,
53
+ downloadFolderAsZip,
54
+ downloadExcelStudy,
55
+ downloadHtmlAsWord,
56
+ openBrowser,
57
+ closeBrowser,
58
+ navigateBrowse,
59
+ browseUp,
60
+ selectBrowseFile,
61
+ confirmBrowseSelection
62
+ });
63
+
@@ -0,0 +1,172 @@
1
+ /*
2
+ Copyright (c) 2026 AdClin
3
+ Licensed under the GNU Affero General Public License v3.0 or later.
4
+ See the LICENSE file for details.
5
+ */
6
+
7
+ import { escapeHtml } from './utils.js';
8
+
9
+ let browseTargetField = null;
10
+ let browseOnSelect = null; // callback(path) - used instead of browseTargetField when target is a function
11
+ let browseMode = 'directory'; // 'directory' or 'file'
12
+ let browseExtFilter = '';
13
+ let browseCurrentPath = '';
14
+ let browseParentPath = null;
15
+
16
+ // Extract the parent directory of a path. Handles both '/' and '\'
17
+ // since paths in this app may come from Windows.
18
+ function dirnameOf(path) {
19
+ const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
20
+ return idx > 0 ? path.slice(0, idx) : '';
21
+ }
22
+
23
+ // target: either a form field id (string) - filled directly on selection -
24
+ // or a callback function(path) for actions that aren't a simple field fill
25
+ // (e.g. loading an existing config file).
26
+ // mode: 'directory' or 'file'. extFilter (file mode only): e.g. '.xml'.
27
+
28
+ export function openBrowser(target, mode, extFilter = '') {
29
+ if (typeof target === 'function') {
30
+ browseTargetField = null;
31
+ browseOnSelect = target;
32
+ } else {
33
+ browseTargetField = target;
34
+ browseOnSelect = null;
35
+ }
36
+ browseMode = mode;
37
+ browseExtFilter = extFilter;
38
+
39
+ const title = document.getElementById('browse-modal-title');
40
+ const selectBtn = document.getElementById('browse-select-btn');
41
+
42
+ if (mode === 'file') {
43
+ title.textContent = `📁 Select a ${extFilter} file`.trim();
44
+ selectBtn.style.display = 'none'; // clicking a file selects it immediately
45
+ } else {
46
+ title.textContent = '📁 Select a directory';
47
+ selectBtn.style.display = '';
48
+ selectBtn.textContent = 'Select this folder';
49
+ }
50
+
51
+ document.getElementById('browse-modal').style.display = 'flex';
52
+
53
+ // Start from the target field's current value when there is one.
54
+ // In 'file' mode the field holds a *file* path, but /browse only lists
55
+ // directories, so we must start from its parent folder instead.
56
+ // Otherwise (or if that parent can't be determined) the server falls
57
+ // back to its launch directory (Dir.pwd).
58
+ const currentValue = browseTargetField ? document.getElementById(browseTargetField).value.trim() : '';
59
+ const startPath = (currentValue && currentValue !== '-')
60
+ ? (mode === 'file' ? dirnameOf(currentValue) : currentValue)
61
+ : '';
62
+ loadBrowsePath(startPath);
63
+ }
64
+
65
+ export function closeBrowser() {
66
+ document.getElementById('browse-modal').style.display = 'none';
67
+ }
68
+
69
+ export async function loadBrowsePath(path) {
70
+ const list = document.getElementById('browse-list');
71
+ list.innerHTML = '<p class="browse-empty">⏳ Loading...</p>';
72
+
73
+ try {
74
+ const url = `/browse?path=${encodeURIComponent(path)}&mode=${browseMode}&ext=${encodeURIComponent(browseExtFilter)}`;
75
+ const response = await fetch(url);
76
+ const data = await response.json();
77
+
78
+ if (!response.ok) {
79
+ list.innerHTML = `<p class="browse-empty">❌ ${escapeHtml(data.error || 'Unable to open this directory')}</p>`;
80
+ return;
81
+ }
82
+
83
+ browseCurrentPath = data.path;
84
+ browseParentPath = data.parent;
85
+ document.getElementById('browse-current-path').value = data.path;
86
+ document.getElementById('browse-up').disabled = !data.parent;
87
+
88
+ renderBrowseList(data);
89
+ } catch (error) {
90
+ list.innerHTML = `<p class="browse-empty">❌ Connection error: ${escapeHtml(error.message)}</p>`;
91
+ }
92
+ }
93
+
94
+ export function renderBrowseList(data) {
95
+ const list = document.getElementById('browse-list');
96
+ const folders = data.folders || [];
97
+ const files = data.files || [];
98
+
99
+ if (folders.length === 0 && files.length === 0) {
100
+ list.innerHTML = '<p class="browse-empty">Empty directory</p>';
101
+ return;
102
+ }
103
+
104
+ let html = '';
105
+
106
+ folders.forEach(entry => {
107
+ html += renderBrowseEntry('📁', entry.name, `navigateBrowse('${escapeAttr(entry.path)}')`);
108
+ });
109
+
110
+ // The server now returns files in both modes. In 'file' mode, clicking
111
+ // a file selects that exact file. In 'directory' mode, files are shown
112
+ // only so the user can confirm they're in the right place; clicking one
113
+ // selects the directory it lives in (the currently displayed directory).
114
+ files.forEach(entry => {
115
+ const onClickCall = browseMode === 'file'
116
+ ? `selectBrowseFile('${escapeAttr(entry.path)}')`
117
+ : 'confirmBrowseSelection()';
118
+ html += renderBrowseEntry('📄', entry.name, onClickCall);
119
+ });
120
+
121
+ list.innerHTML = html;
122
+ }
123
+
124
+ export function renderBrowseEntry(icon, name, onClickCall) {
125
+ return `
126
+ <div class="browse-entry" role="option" tabindex="0"
127
+ onclick="${onClickCall}"
128
+ onkeydown="if(event.key==='Enter'){${onClickCall}}">
129
+ ${icon} ${escapeHtml(name)}
130
+ </div>
131
+ `;
132
+ }
133
+
134
+ export function navigateBrowse(path) {
135
+ loadBrowsePath(path);
136
+ }
137
+
138
+ export function browseUp() {
139
+ if (browseParentPath) loadBrowsePath(browseParentPath);
140
+ }
141
+
142
+ export function selectBrowseFile(fullPath) {
143
+ if (browseOnSelect) {
144
+ browseOnSelect(fullPath);
145
+ } else {
146
+ document.getElementById(browseTargetField).value = fullPath;
147
+ }
148
+ closeBrowser();
149
+ }
150
+
151
+ export function confirmBrowseSelection() {
152
+ if (browseOnSelect) {
153
+ browseOnSelect(browseCurrentPath);
154
+ } else {
155
+ document.getElementById(browseTargetField).value = browseCurrentPath;
156
+ }
157
+ closeBrowser();
158
+ }
159
+
160
+ // Escape a value for safe interpolation inside an inline onclick attribute.
161
+
162
+ export function escapeAttr(text) {
163
+ return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
164
+ }
165
+
166
+ // Close the browser modal on Escape
167
+ document.addEventListener('keydown', (e) => {
168
+ if (e.key === 'Escape') {
169
+ const modal = document.getElementById('browse-modal');
170
+ if (modal && modal.style.display !== 'none') closeBrowser();
171
+ }
172
+ });
@@ -0,0 +1,214 @@
1
+ /*
2
+ Copyright (c) 2026 AdClin
3
+ Licensed under the GNU Affero General Public License v3.0 or later.
4
+ See the LICENSE file for details.
5
+ */
6
+
7
+ import { showMessage } from './utils.js';
8
+ import { openBrowser } from './browser.js';
9
+
10
+ // Configuration Management
11
+
12
+ export async function configSave() {
13
+ // Validate required fields
14
+ const requiredFields = [
15
+ 'study_name', 'output_directory', 'data_directory',
16
+ 'define_path', 'event_key', 'intervention_key', 'finding_key',
17
+ 'finding_about_key', 'ds_key', 'relrec_key', 'CO_key',
18
+ 'TA_key', 'TE_key', 'TI_key', 'TS_key', 'TV_key'
19
+ ];
20
+
21
+ const missingFields = [];
22
+ for (const field of requiredFields) {
23
+ const element = document.getElementById(field);
24
+ if (!element.value.trim()) {
25
+ missingFields.push(field.replace(/_/g, ' ').toUpperCase());
26
+ element.classList.add('error-field');
27
+ } else {
28
+ element.classList.remove('error-field');
29
+ }
30
+ }
31
+
32
+ if (missingFields.length > 0) {
33
+ showMessage('config-issue', 'error',
34
+ `⚠️ Missing required fields: ${missingFields.join(', ')}`);
35
+ return false;
36
+ }
37
+
38
+ const config = {
39
+ study_name: document.getElementById('study_name').value.trim(),
40
+ output_type: 'csv',
41
+ output_directory: document.getElementById('output_directory').value.trim(),
42
+ data_directory: document.getElementById('data_directory').value.trim(),
43
+ define_path: document.getElementById('define_path').value.trim(),
44
+ excluded_ds: document.getElementById('excluded_ds').value.trim(),
45
+ event_key: document.getElementById('event_key').value.trim(),
46
+ intervention_key: document.getElementById('intervention_key').value.trim(),
47
+ finding_key: document.getElementById('finding_key').value.trim(),
48
+ finding_about_key: document.getElementById('finding_about_key').value.trim(),
49
+ ds_key: document.getElementById('ds_key').value.trim(),
50
+ relrec_key: document.getElementById('relrec_key').value.trim(),
51
+ CO_key: document.getElementById('CO_key').value.trim(),
52
+ TA_key: document.getElementById('TA_key').value.trim(),
53
+ TE_key: document.getElementById('TE_key').value.trim(),
54
+ TI_key: document.getElementById('TI_key').value.trim(),
55
+ TS_key: document.getElementById('TS_key').value.trim(),
56
+ TV_key: document.getElementById('TV_key').value.trim()
57
+ };
58
+
59
+ showMessage('config-issue', 'processing', '⏳ Saving configuration...');
60
+
61
+ try {
62
+ const response = await fetch('/config', {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify(config)
66
+ });
67
+
68
+ const data = await response.json();
69
+
70
+ if (data.success) {
71
+ showMessage('config-issue', 'success', '✅ ' + data.message);
72
+ return true;
73
+ } else {
74
+ showMessage('config-issue', 'error', '❌ ' + data.error);
75
+ return false;
76
+ }
77
+ } catch (error) {
78
+ showMessage('config-issue', 'error', '❌ Connection error: ' + error.message);
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // Display full configuration
84
+
85
+ export async function fullConfigViewer() {
86
+ try {
87
+ const response = await fetch('/config');
88
+ const config = await response.json();
89
+
90
+ const configWindow = window.open('', 'Configuration', 'width=700,height=500');
91
+ configWindow.document.write(`
92
+ <!DOCTYPE html>
93
+ <html>
94
+ <head>
95
+ <title>Current Configuration</title>
96
+ <style>
97
+ body {
98
+ font-family: 'Courier New', monospace;
99
+ padding: 20px;
100
+ background: #f5f5f5;
101
+ }
102
+ pre {
103
+ background: white;
104
+ padding: 20px;
105
+ border-radius: 8px;
106
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
107
+ overflow: auto;
108
+ }
109
+ h1 { color: #333; }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <h1>Current Configuration</h1>
114
+ <pre>${JSON.stringify(config, null, 2)}</pre>
115
+ </body>
116
+ </html>
117
+ `);
118
+ } catch (error) {
119
+ showMessage('config-issue', 'error', '❌ Error loading configuration: ' + error.message);
120
+ }
121
+ }
122
+
123
+ // Clear all fields
124
+
125
+ export async function clearFields() {
126
+ if (!confirm('⚠️ Are you sure you want to clear the current configuration (config.yaml)?')) {
127
+ return;
128
+ }
129
+
130
+ showMessage('config-issue', 'processing', '⏳ Clearing configuration...');
131
+
132
+ try {
133
+ const response = await fetch('/config/clear', {
134
+ method: 'POST'
135
+ });
136
+
137
+ const data = await response.json();
138
+
139
+ if (data.success) {
140
+ showMessage('config-issue', 'success', '✅ ' + data.message);
141
+ setTimeout(() => location.reload(true), 1000);
142
+ } else {
143
+ showMessage('config-issue', 'error', '❌ ' + data.error);
144
+ }
145
+ } catch (error) {
146
+ showMessage('config-issue', 'error', '❌ Error: ' + error.message);
147
+ }
148
+ }
149
+
150
+ // Load default configuration
151
+
152
+ export async function loadDefaultConfig() {
153
+ if (!confirm('⚠️ Load default configuration? This will replace config.yaml with config.default.yaml')) {
154
+ return;
155
+ }
156
+
157
+ showMessage('config-issue', 'processing', '⏳ Loading config.default.yaml...');
158
+
159
+ try {
160
+ const response = await fetch('/config/reset', {
161
+ method: 'POST'
162
+ });
163
+
164
+ const data = await response.json();
165
+
166
+ if (data.success) {
167
+ showMessage('config-issue', 'success', '✅ ' + data.message);
168
+ setTimeout(() => location.reload(true), 1000);
169
+ } else {
170
+ showMessage('config-issue', 'error', '❌ ' + data.error);
171
+ }
172
+ } catch (error) {
173
+ showMessage('config-issue', 'error', '❌ Error: ' + error.message);
174
+ }
175
+ }
176
+
177
+ // Loads an arbitrary config file (picked via the file browser) into
178
+ // config.yaml - same idea as loadDefaultConfig() above, but sourced from
179
+ // a user-chosen .yaml file instead of config.default.yaml.
180
+
181
+ export function loadExistingConfig() {
182
+ openBrowser((path) => applyExistingConfig(path), 'file', '.yaml');
183
+ }
184
+
185
+ export async function applyExistingConfig(path) {
186
+ if (!confirm(`⚠️ Load "${path}"? This will replace config.yaml with this file.`)) {
187
+ return;
188
+ }
189
+
190
+ showMessage('config-issue', 'processing', '⏳ Loading ' + path + '...');
191
+
192
+ try {
193
+ const response = await fetch('/config/load_existing', {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({ path })
197
+ });
198
+
199
+ const data = await response.json();
200
+
201
+ if (data.success) {
202
+ showMessage('config-issue', 'success', '✅ ' + data.message);
203
+ setTimeout(() => location.reload(true), 1000);
204
+ } else {
205
+ showMessage('config-issue', 'error', '❌ ' + data.error);
206
+ }
207
+ } catch (error) {
208
+ showMessage('config-issue', 'error', '❌ Error: ' + error.message);
209
+ }
210
+ }
211
+
212
+ // Processing
213
+
214
+ // Start processing