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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7295d19e11dcf4c08317e5549598438fb5a27914c7ea950bb1c71d4e1e94f4a4
4
- data.tar.gz: 4150f90f58274c3bba610afd96379d130ee81338243547574ac0c40a4d04469a
3
+ metadata.gz: f3b17a6ed8bef54db026c08b70fe32e9dde8f9911c8e6369729f182766a39f5d
4
+ data.tar.gz: b7d0a4f7b54af98bea7b745da32e0176a24cf8f4f48778dfe11e7fa316d7b43e
5
5
  SHA512:
6
- metadata.gz: 6fbf1ef581651af787a0db80415a5dbbcb1099cf9dc89885aa26dfafd80e94d52329dd75994a0c718949ff3e39237596c1d3f9775d3108062b6505489e01218a
7
- data.tar.gz: 386d33f35bd9b465f08c0b65379da7e881885953a01a4ad7372a0da23d5735cd0775efa7b310973b17fb9bfac603f0627b6d289dd0a24d250ac9e290c25381a0
6
+ metadata.gz: 1cb8f37f4361669ab689100d4ae1bbc3d4f4a0921ac8b4dd89ef1964eed361c8f85fd0327a8c9768e77e5e62c7e73a8a7872bdd269afb9d6fc4c31c797831e67
7
+ data.tar.gz: e2b4b360a4b825fdefb4a6c16ceb8f643a2e26871c4f525c5dec1b29a9cd224161ac8121d10bb61eb87cc93f8b10fba6b2df960f7482d093c0964ffcb9e8d10c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
- ## 1.0.0 (2026-02-23)
4
- - Initial release
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
  [![Gem Version](https://img.shields.io/gem/v/hlsv.svg)](https://rubygems.org/gems/hlsv)
9
- ![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.0-red.svg)
9
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D4.0-red.svg)
10
10
  ![License](https://img.shields.io/badge/license-GNU%20AGPLv3-green.svg)
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** >= 3.0
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 # Executable β€” starts the server
206
+ β”‚ └── hlsv # Executable β€” starts the server
209
207
  β”œβ”€β”€ lib/
210
- β”‚ β”œβ”€β”€ hlsv.rb # Entry point β€” loads all components
208
+ β”‚ β”œβ”€β”€ hlsv.rb # Entry point β€” loads all components
211
209
  β”‚ └── hlsv/
212
- β”‚ β”œβ”€β”€ version.rb # Gem version
213
- β”‚ β”œβ”€β”€ web_app.rb # Sinatra web application (routes, helpers)
214
- β”‚ β”œβ”€β”€ mon_script.rb # Orchestration layer
215
- β”‚ β”œβ”€β”€ find_keys.rb # Analysis engine
216
- β”‚ β”œβ”€β”€ html2word.rb # HTML to DOCX converter
217
- β”‚ └── xpt.rb # XPT file reader
218
- β”œβ”€β”€ views/ # ERB templates
219
- β”‚ β”œβ”€β”€ index.erb # Main interface
220
- β”‚ β”œβ”€β”€ csv_view.erb # CSV viewer
221
- β”‚ └── report_template.erb # HTML report template
222
- β”œβ”€β”€ public/ # Static assets
223
- β”‚ β”œβ”€β”€ app.js
224
- β”‚ β”œβ”€β”€ styles.css
225
- β”‚ β”œβ”€β”€ styles_csv.css
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
- ### Version 1.0.0 (2026-02-23)
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
@@ -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 : <div class="info-box"><div flex><p>date</p><button>πŸ“„ Word</button></div></div>
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
- # Construit le <w:pPr> avec les options communes
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
- # Paragraphe gΓ©nΓ©rique
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
- # Paragraphe avec style Word nommΓ©
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
- # Paragraphe colorΓ© pour les statuts
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
- # Item de liste Γ  puces
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
- # ── Footer Word ────────────────────────────────────────────────────────────
267
- # Deux zones sΓ©parΓ©es par une tabulation centrale/droite :
268
- # gauche : titre du document
269
- # droite : PAGE / NUMPAGES (champs Word natifs)
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
- # Les champs PAGE et NUMPAGES sont insΓ©rΓ©s via <w:fldChar> / <w:instrText>
272
- # β€” mΓ©canisme identique Γ  la TOC, sans dΓ©pendance externe.
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
- # Champ PAGE
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
- # Champ NUMPAGES
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
- # Page de couverture
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 sur sa propre page
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
- # Corps du document
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
- # Relations du document : styles, numbering, settings, header, footer
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"?>