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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3b17a6ed8bef54db026c08b70fe32e9dde8f9911c8e6369729f182766a39f5d
|
|
4
|
+
data.tar.gz: b7d0a4f7b54af98bea7b745da32e0176a24cf8f4f48778dfe11e7fa316d7b43e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cb8f37f4361669ab689100d4ae1bbc3d4f4a0921ac8b4dd89ef1964eed361c8f85fd0327a8c9768e77e5e62c7e73a8a7872bdd269afb9d6fc4c31c797831e67
|
|
7
|
+
data.tar.gz: e2b4b360a4b825fdefb4a6c16ceb8f643a2e26871c4f525c5dec1b29a9cd224161ac8121d10bb61eb87cc93f8b10fba6b2df960f7482d093c0964ffcb9e8d10c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## [2.0.0] - 2026-08-31
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Complete refactor of the application architecture: the monolithic `web_app.rb` was split into modular components
|
|
9
|
+
- `analysis_registry.rb`,
|
|
10
|
+
- `analysis_runner.rb`,
|
|
11
|
+
- `config_manager.rb`,
|
|
12
|
+
- `path_guard.rb`,
|
|
13
|
+
- `url_helper.rb`
|
|
14
|
+
- Frontend `app.js` split into ES modules with `app.js` as the entry point re-exposing functions on `window` for inline `onclick` handlers in the ERB views:
|
|
15
|
+
- `config.js`,
|
|
16
|
+
- `analysis.js` ,
|
|
17
|
+
- `results.js`,
|
|
18
|
+
- `browser.js`,
|
|
19
|
+
- `utils.js`
|
|
20
|
+
- `styles.css` restructured into:
|
|
21
|
+
- `css/base/` (reset, typography, layout),
|
|
22
|
+
- `css/components/` (header, footer, forms, buttons, status, file-tree),
|
|
23
|
+
- `css/features/` (file-browser modal, csv-table),
|
|
24
|
+
- `css/responsive/`,
|
|
25
|
+
- and `css/accessibility/`
|
|
26
|
+
, assembled via `@import` in `css/styles.css`
|
|
27
|
+
- `report.erb` status boxes (info/warning/error/success) harmonized with the app's `status.css` palette, with text and TOC link colors matched to border colors for stronger contrast
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- favicon.ico
|
|
31
|
+
|
|
32
|
+
### Removed / Breaking
|
|
33
|
+
- Calls to `abort`/`exit` replaced with `raise Hlsv::Error`: code relying on process-exit behavior (e.g. catching `SystemExit`) will need to catch `Hlsv::Error` instead
|
|
34
|
+
- `mon_script.rb` renamed to `analysis_runner.rb`
|
|
35
|
+
- `find_keys.rb` renamed to `sdtm_validation.rb`
|
|
36
|
+
- `report_template.erb` renamed to `report.erb`
|
|
37
|
+
|
|
38
|
+
## [1.0.0] - 2026-02-23
|
|
39
|
+
|
|
40
|
+
- Initial release as a Ruby gem
|
|
41
|
+
- Modern responsive web interface
|
|
42
|
+
- Excel export with README sheet
|
|
43
|
+
- ASCII validation
|
|
44
|
+
- Define.xml key verification
|
|
45
|
+
- Natural key discovery
|
|
46
|
+
- Mobile responsive design
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
An open-source SDTM structural validation tool for clinical data teams.
|
|
7
7
|
|
|
8
8
|
[](https://rubygems.org/gems/hlsv)
|
|
9
|
-

|
|
10
10
|

|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -52,7 +52,7 @@ All processing is performed locally, ensuring full data confidentiality.
|
|
|
52
52
|
|
|
53
53
|
## π§ Prerequisites
|
|
54
54
|
|
|
55
|
-
- **Ruby** >=
|
|
55
|
+
- **Ruby** >= 4.0
|
|
56
56
|
- **RubyGems** (bundled with Ruby)
|
|
57
57
|
|
|
58
58
|
All gem dependencies are installed automatically.
|
|
@@ -115,7 +115,6 @@ in the directory where you launch `hlsv`.
|
|
|
115
115
|
| Parameter | Type | Description |
|
|
116
116
|
|-----------|------|-------------|
|
|
117
117
|
| `study_name` | string | Unique identifier for your study |
|
|
118
|
-
| `output_type` | string | Output format: `csv` (web interface) |
|
|
119
118
|
| `output_directory` | string | Directory to save duplicate files |
|
|
120
119
|
| `data_directory` | string | Path to your `.xpt` dataset files |
|
|
121
120
|
| `define_path` | string | Path to `define.xml`; use `"-"` to skip |
|
|
@@ -132,7 +131,6 @@ in the directory where you launch `hlsv`.
|
|
|
132
131
|
|
|
133
132
|
```yaml
|
|
134
133
|
study_name: "MY_STUDY_001"
|
|
135
|
-
output_type: "csv"
|
|
136
134
|
output_directory: "duplicates"
|
|
137
135
|
data_directory: "/path/to/datasets"
|
|
138
136
|
define_path: "/path/to/define.xml"
|
|
@@ -205,24 +203,41 @@ first column, with alternating row colors for visual clarity.
|
|
|
205
203
|
```
|
|
206
204
|
hlsv/
|
|
207
205
|
βββ bin/
|
|
208
|
-
β βββ hlsv
|
|
206
|
+
β βββ hlsv # Executable β starts the server
|
|
209
207
|
βββ lib/
|
|
210
|
-
β βββ hlsv.rb
|
|
208
|
+
β βββ hlsv.rb # Entry point β loads all components
|
|
211
209
|
β βββ hlsv/
|
|
212
|
-
β βββ version.rb
|
|
213
|
-
β βββ web_app.rb
|
|
214
|
-
β βββ
|
|
215
|
-
β βββ
|
|
216
|
-
β βββ
|
|
217
|
-
β
|
|
218
|
-
βββ
|
|
219
|
-
β
|
|
220
|
-
β
|
|
221
|
-
β
|
|
222
|
-
βββ
|
|
223
|
-
β βββ
|
|
224
|
-
β βββ
|
|
225
|
-
β
|
|
210
|
+
β βββ version.rb # Gem version
|
|
211
|
+
β βββ web_app.rb # Sinatra web application (routes, helpers)
|
|
212
|
+
β βββ config_manager.rb # Configuration loading/saving
|
|
213
|
+
β βββ analysis_registry.rb # In-memory fallback store for completed analyses, keyed by study name
|
|
214
|
+
β βββ path_guard.rb # Path traversal protection
|
|
215
|
+
β βββ url_helper.rb # URL building/parsing helpers
|
|
216
|
+
β βββ analysis_runner.rb # Orchestration layer
|
|
217
|
+
β βββ sdtm_validation.rb # Analysis engine
|
|
218
|
+
β βββ html2word.rb # HTML to DOCX converter
|
|
219
|
+
β βββ xpt.rb # XPT file reader
|
|
220
|
+
βββ views/ # ERB templates
|
|
221
|
+
β βββ index.erb # Main interface
|
|
222
|
+
β βββ csv_view.erb # CSV viewer
|
|
223
|
+
β βββ report.erb # HTML report template
|
|
224
|
+
βββ public/ # Static assets
|
|
225
|
+
β βββ js/
|
|
226
|
+
β β βββ app.js # Entry point β re-exposes functions on window
|
|
227
|
+
β β βββ config.js
|
|
228
|
+
β β βββ analysis.js
|
|
229
|
+
β β βββ results.js
|
|
230
|
+
β β βββ browser.js
|
|
231
|
+
β β βββ utils.js
|
|
232
|
+
β βββ css/
|
|
233
|
+
β β βββ styles.css # Assembles all partials via @import for index.erb
|
|
234
|
+
β β βββ styles_csv.css # Assembles all partials via @import for csv_view.erb
|
|
235
|
+
β β βββ base/ # Reset, typography, layout
|
|
236
|
+
β β βββ components/ # Header, footer, forms, buttons, status, file-tree
|
|
237
|
+
β β βββ features/ # File-browser modal
|
|
238
|
+
β β βββ responsive/
|
|
239
|
+
β β βββ accessibility/
|
|
240
|
+
β βββ favicon.ico
|
|
226
241
|
β βββ logo.png
|
|
227
242
|
βββ hlsv.gemspec # Gem specification
|
|
228
243
|
βββ Gemfile # Development dependencies
|
|
@@ -341,15 +356,7 @@ AdClin offers professional services related to this tool:
|
|
|
341
356
|
|
|
342
357
|
## ποΈ Changelog
|
|
343
358
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
- β¨ Initial release as a Ruby gem
|
|
347
|
-
- π¨ Modern responsive web interface
|
|
348
|
-
- π Excel export with README sheet
|
|
349
|
-
- π ASCII validation
|
|
350
|
-
- β
Define.xml key verification
|
|
351
|
-
- π Natural key discovery
|
|
352
|
-
- π± Mobile responsive design
|
|
359
|
+
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
|
|
353
360
|
|
|
354
361
|
---
|
|
355
362
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#######
|
|
2
|
+
# Copyright (c) 2026 AdClin
|
|
3
|
+
# Licensed under the GNU General Public License v3.0
|
|
4
|
+
#######
|
|
5
|
+
|
|
6
|
+
# frozen_string_literal: true
|
|
7
|
+
|
|
8
|
+
module Hlsv
|
|
9
|
+
# Keeps completed SdtmValidation instances in memory, keyed by study name.
|
|
10
|
+
#
|
|
11
|
+
# Historically this was the only way to generate html/docx/excel reports
|
|
12
|
+
# on demand. Since '/proceed' now generates all report formats up front
|
|
13
|
+
# (see AnalysisRunner), this registry is no longer on the main path: it
|
|
14
|
+
# only serves as a fallback
|
|
15
|
+
# - to regenerate a report format that failed to generate during
|
|
16
|
+
# '/proceed' (see the per-format rescue blocks there), or
|
|
17
|
+
# - for the per-dataset Excel export ('/report/:study/excel/:dataset'),
|
|
18
|
+
# which is still generated on click rather than pre-generated.
|
|
19
|
+
#
|
|
20
|
+
# This is process-local, in-memory state: it is lost on server restart
|
|
21
|
+
# and is not shared across multiple server processes. That's an accepted
|
|
22
|
+
# trade-off for a single-user local tool. If this ever needs to survive
|
|
23
|
+
# restarts or run behind multiple workers, this is the place to swap the
|
|
24
|
+
# storage for something persistent.
|
|
25
|
+
class AnalysisRegistry
|
|
26
|
+
class << self
|
|
27
|
+
def store(study_name, engine)
|
|
28
|
+
mutex.synchronize { engines[study_name] = engine }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fetch!(study_name)
|
|
32
|
+
mutex.synchronize { engines[study_name] } ||
|
|
33
|
+
raise(Hlsv::Error, "No analysis found in memory for '#{study_name}'. Please re-run the analysis.")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def engines
|
|
39
|
+
@engines ||= {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mutex
|
|
43
|
+
@mutex ||= Mutex.new
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Copyright (c) 2026 AdClin
|
|
2
|
+
# Licensed under the GNU Affero General Public License v3.0 or later.
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
# analysis_runner.rb
|
|
6
|
+
# Orchestration layer between the web interface (web_app.rb) and the analysis engine (sdtm_validation.rb)
|
|
7
|
+
|
|
8
|
+
require_relative 'sdtm_validation'
|
|
9
|
+
require_relative 'analysis_registry'
|
|
10
|
+
|
|
11
|
+
module Hlsv
|
|
12
|
+
module AnalysisRunner
|
|
13
|
+
# Execute the complete analysis workflow.
|
|
14
|
+
#
|
|
15
|
+
# Note: this only runs the analysis and keeps the resulting engine in
|
|
16
|
+
# memory (see AnalysisRegistry). It does NOT generate any html/docx/excel
|
|
17
|
+
# report - those are generated on demand, only when the user clicks the
|
|
18
|
+
# corresponding button in the UI (see web_app.rb '/report/*' routes).
|
|
19
|
+
#
|
|
20
|
+
# @param config [Hash] Configuration parameters from the web interface
|
|
21
|
+
# @return [Hash] Detailed execution results including steps, files, and statistics
|
|
22
|
+
def self.run(config)
|
|
23
|
+
steps = []
|
|
24
|
+
temp_config = 'temp_config.yaml'
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
# ========================================
|
|
28
|
+
# STEP 1: Configuration Validation
|
|
29
|
+
# ========================================
|
|
30
|
+
steps << {
|
|
31
|
+
step: 1,
|
|
32
|
+
name: "Configuration validation",
|
|
33
|
+
status: "in_progress"
|
|
34
|
+
}
|
|
35
|
+
puts "=" * 60
|
|
36
|
+
puts "\nStep 1/3: Configuration validation"
|
|
37
|
+
|
|
38
|
+
# Prefix study_name with "hlsv_results/" to create folders in hlsv_results/ directory
|
|
39
|
+
config_with_results = config.dup
|
|
40
|
+
original_study_name = config['study_name']
|
|
41
|
+
config_with_results['study_name'] = "hlsv_results/#{original_study_name}"
|
|
42
|
+
|
|
43
|
+
# Create temporary config file for SdtmValidation
|
|
44
|
+
File.write(temp_config, config_with_results.to_yaml)
|
|
45
|
+
|
|
46
|
+
# Initialize the engine (web_mode enables logo/base64 embedding etc. in later reports)
|
|
47
|
+
sv = SdtmValidation.new(temp_config, web_mode: true, verbose: false)
|
|
48
|
+
|
|
49
|
+
steps[0][:status] = "completed"
|
|
50
|
+
steps[0][:message] = "Configuration validated successfully"
|
|
51
|
+
|
|
52
|
+
# ========================================
|
|
53
|
+
# STEP 2: Analysis Execution
|
|
54
|
+
# ========================================
|
|
55
|
+
steps << {
|
|
56
|
+
step: 2,
|
|
57
|
+
name: "Analysis execution",
|
|
58
|
+
status: "in_progress"
|
|
59
|
+
}
|
|
60
|
+
puts "\nStep 2/3: Analysis execution"
|
|
61
|
+
|
|
62
|
+
sv.validate
|
|
63
|
+
|
|
64
|
+
steps[1][:status] = "completed"
|
|
65
|
+
steps[1][:message] = "Analysis completed successfully"
|
|
66
|
+
|
|
67
|
+
# ========================================
|
|
68
|
+
# STEP 3: Report Generation
|
|
69
|
+
# ========================================
|
|
70
|
+
# Generate all report formats up front so they're guaranteed present on
|
|
71
|
+
# disk (needed e.g. by "Download as ZIP"), even if the server restarts
|
|
72
|
+
# before the user clicks an individual '/report/*' button.
|
|
73
|
+
# Each format is generated independently: a failure on one (e.g. missing
|
|
74
|
+
# HTML template) must not block the others or fail the whole analysis,
|
|
75
|
+
# since the analysis itself already succeeded at this point.
|
|
76
|
+
steps << {
|
|
77
|
+
step: 3,
|
|
78
|
+
name: "Report generation",
|
|
79
|
+
status: "in_progress"
|
|
80
|
+
}
|
|
81
|
+
puts "\nStep 3/3: Report generation"
|
|
82
|
+
|
|
83
|
+
report_errors = []
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
sv.report.html
|
|
87
|
+
rescue => e
|
|
88
|
+
puts "Warning: HTML report generation failed: #{e.message}"
|
|
89
|
+
report_errors << "html: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
sv.report.docx
|
|
94
|
+
rescue => e
|
|
95
|
+
puts "Warning: Word report generation failed: #{e.message}"
|
|
96
|
+
report_errors << "docx: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
sv.report.excel # no-op if no duplicates found
|
|
101
|
+
rescue => e
|
|
102
|
+
puts "Warning: Excel report generation failed: #{e.message}"
|
|
103
|
+
report_errors << "excel: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if report_errors.empty?
|
|
107
|
+
steps[2][:status] = "completed"
|
|
108
|
+
steps[2][:message] = "Reports generated successfully"
|
|
109
|
+
else
|
|
110
|
+
steps[2][:status] = "completed"
|
|
111
|
+
steps[2][:message] = "Reports generated with #{report_errors.size} warning#{'s' if report_errors.size > 1} (see details below)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ========================================
|
|
115
|
+
# Keep the engine in memory for on-demand report *re*-generation
|
|
116
|
+
# (per-dataset excel exports, or regenerating after a manual fix)
|
|
117
|
+
# ========================================
|
|
118
|
+
AnalysisRegistry.store(original_study_name, sv)
|
|
119
|
+
|
|
120
|
+
# ========================================
|
|
121
|
+
# Post-Processing: Save Reference Config File
|
|
122
|
+
# ========================================
|
|
123
|
+
# Move the temp config file to the study directory for reference.
|
|
124
|
+
config_destination = "hlsv_results/#{original_study_name}/config.yaml"
|
|
125
|
+
File.write(config_destination, config.to_yaml) if Dir.exist?(File.dirname(config_destination))
|
|
126
|
+
File.delete(temp_config) if File.exist?(temp_config)
|
|
127
|
+
|
|
128
|
+
puts "All steps completed successfully"
|
|
129
|
+
puts "=" * 60
|
|
130
|
+
|
|
131
|
+
build_result_summary(sv: sv, steps: steps, original_study_name: original_study_name, report_errors: report_errors)
|
|
132
|
+
|
|
133
|
+
rescue => e
|
|
134
|
+
handle_execution_error(
|
|
135
|
+
error: e,
|
|
136
|
+
steps: steps,
|
|
137
|
+
temp_config: temp_config
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Build a comprehensive result summary. Note: no report path is returned
|
|
145
|
+
# here since nothing has been generated yet - the front-end uses
|
|
146
|
+
# `study_name` and `datasets_with_duplicates` to know which '/report/*'
|
|
147
|
+
# buttons to render.
|
|
148
|
+
def self.build_result_summary(sv:, steps:, original_study_name:, report_errors:)
|
|
149
|
+
ds_with_issues = sv.datasets.values.select do |ds|
|
|
150
|
+
ds.data_duplicate_file.any? || !ds.define_duplicate_file.to_s.empty?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
invalid_ascii = sv.datasets.values.select { |ds| ds.non_ascii_values&.any? }.map(&:name)
|
|
154
|
+
invalid_define = sv.datasets.values.select { |ds| ds.define_status == false }.map(&:name)
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
message: "Analysis completed successfully",
|
|
158
|
+
steps: steps,
|
|
159
|
+
|
|
160
|
+
study_name: original_study_name,
|
|
161
|
+
output_directory: sv.dir_out.split('/')[1..].join('/'),
|
|
162
|
+
|
|
163
|
+
analyzed_dataset_count: sv.datasets.size,
|
|
164
|
+
datasets_with_duplicates: ds_with_issues.map(&:name),
|
|
165
|
+
|
|
166
|
+
# Non-fatal report generation failures (html/docx/excel), if any -
|
|
167
|
+
# the analysis itself succeeded regardless
|
|
168
|
+
report_warnings: report_errors,
|
|
169
|
+
|
|
170
|
+
invalid_dataset_summary: {
|
|
171
|
+
invalid_dataset: ds_with_issues.size,
|
|
172
|
+
invalid_ascii: invalid_ascii.empty? ? nil : invalid_ascii.join(', '),
|
|
173
|
+
invalid_define: invalid_define.empty? ? nil : invalid_define.join(', '),
|
|
174
|
+
invalid_data: ds_with_issues.empty? ? nil : ds_with_issues.map(&:name).join(', ')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Handle execution errors gracefully
|
|
180
|
+
def self.handle_execution_error(error:, steps:, temp_config:)
|
|
181
|
+
current_step = steps.find { |st| st[:status] == "in_progress" }
|
|
182
|
+
if current_step
|
|
183
|
+
current_step[:status] = "error"
|
|
184
|
+
current_step[:message] = error.message
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
File.delete(temp_config) if File.exist?(temp_config)
|
|
188
|
+
|
|
189
|
+
error_details = steps.map { |st| "#{st[:name]}: #{st[:status]}" }.join("\n")
|
|
190
|
+
raise "#{error.message}\n\nExecution steps:\n#{error_details}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#######
|
|
2
|
+
# Copyright (c) 2026 AdClin
|
|
3
|
+
# Licensed under the GNU General Public License v3.0
|
|
4
|
+
#######
|
|
5
|
+
|
|
6
|
+
# frozen_string_literal: true
|
|
7
|
+
|
|
8
|
+
require 'yaml'
|
|
9
|
+
|
|
10
|
+
module Hlsv
|
|
11
|
+
# Centralizes everything related to config.yaml: field definitions,
|
|
12
|
+
# loading, saving, resetting, clearing and validating the configuration.
|
|
13
|
+
#
|
|
14
|
+
# Having a single FIELDS table avoids the previous situation where the
|
|
15
|
+
# same set of keys was duplicated across three different places
|
|
16
|
+
# (editable fields, required fields, empty-config template).
|
|
17
|
+
class ConfigManager
|
|
18
|
+
# key => { label:, required: }
|
|
19
|
+
# required: false means the field is editable/clearable but not
|
|
20
|
+
# mandatory for processing (e.g. excluded_ds).
|
|
21
|
+
FIELDS = {
|
|
22
|
+
'study_name' => { label: 'Study name', required: true },
|
|
23
|
+
'output_directory' => { label: 'Output directory', required: true },
|
|
24
|
+
'data_directory' => { label: 'Data directory', required: true },
|
|
25
|
+
'define_path' => { label: 'Define.xml path', required: true },
|
|
26
|
+
'excluded_ds' => { label: 'Excluded datasets', required: false },
|
|
27
|
+
'event_key' => { label: 'Event key', required: true },
|
|
28
|
+
'intervention_key' => { label: 'Intervention key', required: true },
|
|
29
|
+
'finding_key' => { label: 'Finding key', required: true },
|
|
30
|
+
'finding_about_key' => { label: 'Finding about key', required: true },
|
|
31
|
+
'ds_key' => { label: 'DS key', required: true },
|
|
32
|
+
'relrec_key' => { label: 'RELREC key', required: true },
|
|
33
|
+
'CO_key' => { label: 'CO key', required: true },
|
|
34
|
+
'TA_key' => { label: 'TA key', required: true },
|
|
35
|
+
'TE_key' => { label: 'TE key', required: true },
|
|
36
|
+
'TI_key' => { label: 'TI key', required: true },
|
|
37
|
+
'TS_key' => { label: 'TS key', required: true },
|
|
38
|
+
'TV_key' => { label: 'TV key', required: true }
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
EDITABLE_FIELDS = FIELDS.keys.freeze
|
|
42
|
+
REQUIRED_FIELDS = FIELDS.select { |_, v| v[:required] }.freeze
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Loads and returns config.yaml as a Hash. Halts with 500 if file is missing.
|
|
46
|
+
# `halt` is injected by the caller (Sinatra helper) since this class
|
|
47
|
+
# has no direct access to Sinatra's request context.
|
|
48
|
+
def load
|
|
49
|
+
return YAML.load_file(Hlsv.config_path) || {} if File.exist?(Hlsv.config_path)
|
|
50
|
+
|
|
51
|
+
raise "File config.yaml not found"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Merges config_params into the existing config.yaml, for editable fields only.
|
|
55
|
+
# output_type is always forced to 'csv'.
|
|
56
|
+
def save(config_params)
|
|
57
|
+
current_config = File.exist?(Hlsv.config_path) ? (YAML.load_file(Hlsv.config_path) || {}) : {}
|
|
58
|
+
|
|
59
|
+
EDITABLE_FIELDS.each do |field|
|
|
60
|
+
current_config[field] = config_params[field] if config_params.key?(field)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
current_config['output_type'] = 'csv'
|
|
64
|
+
File.write(Hlsv.config_path, current_config.to_yaml)
|
|
65
|
+
current_config
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Reloads config.yaml from config.default.yaml.
|
|
69
|
+
def reset
|
|
70
|
+
raise "File config.default.yaml not found in project root" unless File.exist?(Hlsv.default_config_path)
|
|
71
|
+
|
|
72
|
+
config_default = YAML.load_file(Hlsv.default_config_path)
|
|
73
|
+
File.write(Hlsv.config_path, config_default.to_yaml)
|
|
74
|
+
config_default
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Loads config.yaml from an arbitrary file picked by the user (via the
|
|
78
|
+
# config page's file browser). Same idea as reset above, just sourced
|
|
79
|
+
# from a chosen file instead of config.default.yaml.
|
|
80
|
+
def load_from(path)
|
|
81
|
+
raise "File not found: #{path}" unless File.exist?(path)
|
|
82
|
+
|
|
83
|
+
external_config = YAML.load_file(path)
|
|
84
|
+
raise "#{path} is not a valid configuration file" unless external_config.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
File.write(Hlsv.config_path, external_config.to_yaml)
|
|
87
|
+
external_config
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Resets all config fields to nil (keeps structure).
|
|
91
|
+
def clear
|
|
92
|
+
empty_config = EDITABLE_FIELDS.each_with_object({}) { |field, h| h[field] = nil }
|
|
93
|
+
empty_config['output_type'] = 'csv'
|
|
94
|
+
|
|
95
|
+
File.write(Hlsv.config_path, empty_config.to_yaml)
|
|
96
|
+
empty_config
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Validates the configuration hash.
|
|
100
|
+
# Checks all required fields are present and validates filesystem paths.
|
|
101
|
+
# Returns an array of error messages (empty if config is valid).
|
|
102
|
+
def validate(config)
|
|
103
|
+
errors = []
|
|
104
|
+
|
|
105
|
+
REQUIRED_FIELDS.each do |key, meta|
|
|
106
|
+
value = config[key]
|
|
107
|
+
errors << "#{meta[:label]} is empty" if value.nil? || value.to_s.strip.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if config['data_directory'] && !config['data_directory'].to_s.strip.empty?
|
|
111
|
+
dir = config['data_directory'].gsub('\\', '/')
|
|
112
|
+
errors << "Data directory does not exist: #{dir}" unless Dir.exist?(dir)
|
|
113
|
+
errors << "Directory is empty, no .xpt files detected" if Dir["#{dir}/*"].none? { |f| File.extname(f) == '.xpt' }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if config['define_path'] && config['define_path'] != '-'
|
|
117
|
+
errors << "Invalid path: #{config['define_path']}" unless File.exist?(config['define_path'])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
config['output_type'] = 'csv'
|
|
121
|
+
errors
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/hlsv/html2word.rb
CHANGED
|
@@ -45,7 +45,7 @@ class RiReportParser
|
|
|
45
45
|
|
|
46
46
|
downloaded_at = Time.now.strftime('Downloaded on %Y-%m-%d at %H:%M')
|
|
47
47
|
|
|
48
|
-
# Structure
|
|
48
|
+
# Structure: <div class="info-box"><div flex><p>date</p><button>π Word</button></div></div>
|
|
49
49
|
# target <p> to ignore the button
|
|
50
50
|
date_text = if info
|
|
51
51
|
p_tag = info.at_css('p')
|
|
@@ -191,7 +191,7 @@ module OoxmlBuilder
|
|
|
191
191
|
"<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
-
#
|
|
194
|
+
# Builds the <w:pPr> with the common options
|
|
195
195
|
def self.build_ppr(opts = {})
|
|
196
196
|
style = opts[:style]
|
|
197
197
|
align = opts[:align]
|
|
@@ -209,7 +209,7 @@ module OoxmlBuilder
|
|
|
209
209
|
"<w:pPr>#{inner}</w:pPr>"
|
|
210
210
|
end
|
|
211
211
|
|
|
212
|
-
#
|
|
212
|
+
# Generic paragraph
|
|
213
213
|
def self.paragraph(text, opts = {})
|
|
214
214
|
sz = opts[:sz] || 20
|
|
215
215
|
bold = opts[:bold] || false
|
|
@@ -224,13 +224,13 @@ module OoxmlBuilder
|
|
|
224
224
|
"<w:p>#{ppr}<w:r><w:rPr>#{rpr}</w:rPr><w:t xml:space=\"preserve\">#{x(text)}</w:t></w:r></w:p>"
|
|
225
225
|
end
|
|
226
226
|
|
|
227
|
-
#
|
|
227
|
+
# Paragraph with a named Word style
|
|
228
228
|
def self.heading(text, opts = {})
|
|
229
229
|
ppr = build_ppr(opts)
|
|
230
230
|
"<w:p>#{ppr}<w:r><w:t xml:space=\"preserve\">#{x(text)}</w:t></w:r></w:p>"
|
|
231
231
|
end
|
|
232
232
|
|
|
233
|
-
#
|
|
233
|
+
# Colored paragraph for statuses
|
|
234
234
|
def self.status_paragraph(text, opts = {})
|
|
235
235
|
color = opts[:color] || '000000'
|
|
236
236
|
ppr = build_ppr(opts.merge(space_before: 0, space_after: 80))
|
|
@@ -239,7 +239,7 @@ module OoxmlBuilder
|
|
|
239
239
|
"<w:p>#{ppr}<w:r><w:rPr>#{rpr}</w:rPr><w:t xml:space=\"preserve\">#{x(text)}</w:t></w:r></w:p>"
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
-
#
|
|
242
|
+
# Bulleted list item
|
|
243
243
|
def self.list_item(text, opts = {})
|
|
244
244
|
color = opts[:color] || '000000'
|
|
245
245
|
sz = opts[:sz] || 20
|
|
@@ -263,18 +263,18 @@ module OoxmlBuilder
|
|
|
263
263
|
XML
|
|
264
264
|
end
|
|
265
265
|
|
|
266
|
-
# ββ
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
266
|
+
# ββ Word footer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
267
|
+
# Two zones separated by a center/right tab stop:
|
|
268
|
+
# left : document title
|
|
269
|
+
# right : PAGE / NUMPAGES (native Word fields)
|
|
270
270
|
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
271
|
+
# The PAGE and NUMPAGES fields are inserted via <w:fldChar> / <w:instrText>
|
|
272
|
+
# - same mechanism as the TOC, with no external dependency.
|
|
273
273
|
def self.footer_xml(doc_title)
|
|
274
274
|
rpr_base = "<w:rFonts w:ascii=\"#{FONT}\" w:hAnsi=\"#{FONT}\" w:cs=\"#{FONT}\"/>" \
|
|
275
275
|
"<w:sz w:val=\"18\"/><w:szCs w:val=\"18\"/><w:color w:val=\"666666\"/>"
|
|
276
276
|
|
|
277
|
-
#
|
|
277
|
+
# PAGE field
|
|
278
278
|
page_field = <<~XML
|
|
279
279
|
<w:r><w:rPr>#{rpr_base}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r>
|
|
280
280
|
<w:r><w:rPr>#{rpr_base}</w:rPr><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
|
|
@@ -283,7 +283,7 @@ module OoxmlBuilder
|
|
|
283
283
|
<w:r><w:rPr>#{rpr_base}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>
|
|
284
284
|
XML
|
|
285
285
|
|
|
286
|
-
#
|
|
286
|
+
# NUMPAGES field
|
|
287
287
|
numpages_field = <<~XML
|
|
288
288
|
<w:r><w:rPr>#{rpr_base}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r>
|
|
289
289
|
<w:r><w:rPr>#{rpr_base}</w:rPr><w:instrText xml:space="preserve"> NUMPAGES </w:instrText></w:r>
|
|
@@ -315,7 +315,7 @@ module OoxmlBuilder
|
|
|
315
315
|
XML
|
|
316
316
|
end
|
|
317
317
|
|
|
318
|
-
#
|
|
318
|
+
# Cover page
|
|
319
319
|
def self.cover_page(title:, date:, downloaded_at:)
|
|
320
320
|
title_para = paragraph(title,
|
|
321
321
|
sz: 44, bold: true, color: '1F3864',
|
|
@@ -337,7 +337,7 @@ module OoxmlBuilder
|
|
|
337
337
|
title_para + hr + date_para + downloaded_para + page_break
|
|
338
338
|
end
|
|
339
339
|
|
|
340
|
-
# TOC
|
|
340
|
+
# TOC on its own page
|
|
341
341
|
def self.toc_field
|
|
342
342
|
toc_title = heading('Table of Contents',
|
|
343
343
|
style: 'Heading1', space_before: 0, space_after: 160)
|
|
@@ -357,7 +357,7 @@ module OoxmlBuilder
|
|
|
357
357
|
toc_title + toc_content + page_break
|
|
358
358
|
end
|
|
359
359
|
|
|
360
|
-
#
|
|
360
|
+
# Document body
|
|
361
361
|
def self.document(blocks)
|
|
362
362
|
paras = blocks.map do |b|
|
|
363
363
|
m = b.meta
|
|
@@ -512,7 +512,7 @@ module OoxmlBuilder
|
|
|
512
512
|
XML
|
|
513
513
|
end
|
|
514
514
|
|
|
515
|
-
#
|
|
515
|
+
# Document relationships: styles, numbering, settings, header, footer
|
|
516
516
|
def self.word_rels
|
|
517
517
|
<<~XML
|
|
518
518
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|