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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -3
- data/README.md +36 -29
- data/lib/hlsv/analysis_registry.rb +47 -0
- data/lib/hlsv/analysis_runner.rb +193 -0
- data/lib/hlsv/config_manager.rb +125 -0
- data/lib/hlsv/html2word.rb +18 -18
- data/lib/hlsv/path_guard.rb +32 -0
- data/lib/hlsv/sdtm_validation/dataset.rb +169 -0
- data/lib/hlsv/sdtm_validation/define.rb +142 -0
- data/lib/hlsv/sdtm_validation/report.rb +357 -0
- data/lib/hlsv/sdtm_validation.rb +390 -0
- data/lib/hlsv/url_helper.rb +28 -0
- data/lib/hlsv/version.rb +1 -1
- data/lib/hlsv/web_app.rb +264 -418
- data/lib/hlsv.rb +12 -5
- data/public/css/accessibility/accessibility.css +33 -0
- data/public/css/base/layout.css +33 -0
- data/public/css/base/reset.css +23 -0
- data/public/css/base/typography.css +31 -0
- data/public/css/components/buttons.css +142 -0
- data/public/css/components/file-tree.css +107 -0
- data/public/css/components/footer.css +43 -0
- data/public/css/components/forms.css +56 -0
- data/public/css/components/header.css +52 -0
- data/public/css/components/status.css +56 -0
- data/public/css/features/csv-table.css +204 -0
- data/public/css/features/file-browser.css +208 -0
- data/public/css/responsive/responsive.css +133 -0
- data/public/css/styles.css +25 -0
- data/public/css/styles_csv.css +23 -0
- data/public/favicon.ico +0 -0
- data/public/js/analysis.js +201 -0
- data/public/js/app.js +63 -0
- data/public/js/browser.js +172 -0
- data/public/js/config.js +214 -0
- data/public/js/results.js +240 -0
- data/public/js/utils.js +57 -0
- data/views/csv_view.erb +11 -12
- data/views/index.erb +70 -19
- data/views/{report_template.erb → report.erb} +203 -188
- metadata +39 -41
- data/lib/hlsv/find_keys.rb +0 -979
- data/lib/hlsv/mon_script.rb +0 -169
- data/public/app.js +0 -569
- data/public/styles.css +0 -586
- 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
|
+
});
|
data/public/js/config.js
ADDED
|
@@ -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
|