hlsv 1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +676 -0
- data/README.md +356 -0
- data/bin/hlsv +4 -0
- data/config.default.yaml +19 -0
- data/lib/hlsv/cli.rb +85 -0
- data/lib/hlsv/find_keys.rb +979 -0
- data/lib/hlsv/html2word.rb +602 -0
- data/lib/hlsv/mon_script.rb +169 -0
- data/lib/hlsv/version.rb +5 -0
- data/lib/hlsv/web_app.rb +569 -0
- data/lib/hlsv/xpt/dataset.rb +38 -0
- data/lib/hlsv/xpt/library.rb +28 -0
- data/lib/hlsv/xpt/reader.rb +367 -0
- data/lib/hlsv/xpt/variable.rb +130 -0
- data/lib/hlsv/xpt.rb +11 -0
- data/lib/hlsv.rb +49 -0
- data/public/Contact-LOGO.png +0 -0
- data/public/app.js +569 -0
- data/public/styles.css +586 -0
- data/public/styles_csv.css +448 -0
- data/views/csv_view.erb +85 -0
- data/views/index.erb +233 -0
- data/views/report_template.erb +1144 -0
- metadata +176 -0
data/lib/hlsv/web_app.rb
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
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 'json'
|
|
9
|
+
require 'yaml'
|
|
10
|
+
require 'csv'
|
|
11
|
+
require 'fast_excel'
|
|
12
|
+
require 'zip'
|
|
13
|
+
require 'nokogiri'
|
|
14
|
+
|
|
15
|
+
module Hlsv
|
|
16
|
+
class WebApp < Sinatra::Base
|
|
17
|
+
|
|
18
|
+
set :root, Hlsv::INSTALL_ROOT
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# :section: ROUTES — Views
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
# Homepage: loads config
|
|
25
|
+
get '/' do
|
|
26
|
+
@config = load_config
|
|
27
|
+
erb :index
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# CSV viewer: displays a CSV file as an HTML table
|
|
31
|
+
# Params:
|
|
32
|
+
# :file — path to the CSV file
|
|
33
|
+
# :last_valid_key — comma-separated list of last tested keys (optional)
|
|
34
|
+
get '/csv_view' do
|
|
35
|
+
file = params[:file]
|
|
36
|
+
halt 400, "Missing file parameter" unless file
|
|
37
|
+
halt 403, "Access denied" if file.include?('..') || file.start_with?('/')
|
|
38
|
+
halt 404, "File not found" unless File.exist?(file)
|
|
39
|
+
|
|
40
|
+
csv_name = File.basename(file, '.csv')
|
|
41
|
+
type_dup, @ds_name = csv_name.split('_')
|
|
42
|
+
@type = type_dup == 'data' ? 'dataset' : 'define.xml'
|
|
43
|
+
@last_valid_key = params[:last_valid_key]&.split(',') || []
|
|
44
|
+
@rows = CSV.read(file, headers: true)
|
|
45
|
+
|
|
46
|
+
erb :csv_view
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# :section: ROUTES — Configuration (JSON API)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
# GET /config — Returns the current configuration as JSON
|
|
54
|
+
get '/config' do
|
|
55
|
+
content_type :json
|
|
56
|
+
load_config.to_json
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# POST /config — Updates editable fields in config.yaml
|
|
60
|
+
# Body: JSON object with field/value pairs
|
|
61
|
+
post '/config' do
|
|
62
|
+
content_type :json
|
|
63
|
+
begin
|
|
64
|
+
config_params = JSON.parse(request.body.read)
|
|
65
|
+
save_config(config_params)
|
|
66
|
+
{ success: true, message: "Configuration updated successfully" }.to_json
|
|
67
|
+
rescue => e
|
|
68
|
+
status 500
|
|
69
|
+
{ success: false, erreur: e.message }.to_json
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# POST /config/reset — Reloads config.yaml from config.default.yaml
|
|
74
|
+
post '/config/reset' do
|
|
75
|
+
content_type :json
|
|
76
|
+
begin
|
|
77
|
+
unless File.exist?(Hlsv.default_config_path)
|
|
78
|
+
return { success: false, erreur: "File config.default.yaml not found in project root" }.to_json
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
config_default = YAML.load_file(Hlsv.default_config_path)
|
|
82
|
+
File.write(Hlsv.config_path, config_default.to_yaml)
|
|
83
|
+
{ success: true, message: "Default configuration loaded from config.default.yaml" }.to_json
|
|
84
|
+
rescue => e
|
|
85
|
+
status 500
|
|
86
|
+
{ success: false, erreur: e.message }.to_json
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# POST /config/clear — Resets all config fields to nil (keeps structure)
|
|
91
|
+
post '/config/clear' do
|
|
92
|
+
content_type :json
|
|
93
|
+
begin
|
|
94
|
+
empty_config = {
|
|
95
|
+
'study_name' => nil,
|
|
96
|
+
'output_type' => 'csv',
|
|
97
|
+
'output_directory' => nil,
|
|
98
|
+
'data_directory' => nil,
|
|
99
|
+
'define_path' => nil,
|
|
100
|
+
'excluded_ds' => nil,
|
|
101
|
+
'event_key' => nil,
|
|
102
|
+
'intervention_key' => nil,
|
|
103
|
+
'finding_key' => nil,
|
|
104
|
+
'finding_about_key' => nil,
|
|
105
|
+
'ds_key' => nil,
|
|
106
|
+
'relrec_key' => nil,
|
|
107
|
+
'CO_key' => nil,
|
|
108
|
+
'TA_key' => nil,
|
|
109
|
+
'TE_key' => nil,
|
|
110
|
+
'TI_key' => nil,
|
|
111
|
+
'TS_key' => nil,
|
|
112
|
+
'TV_key' => nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
File.write(Hlsv.config_path, empty_config.to_yaml)
|
|
116
|
+
{ success: true, message: "Configuration cleared" }.to_json
|
|
117
|
+
rescue => e
|
|
118
|
+
status 500
|
|
119
|
+
{ success: false, erreur: e.message }.to_json
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# :section: ROUTES — Processing
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
# POST /traiter — Validates config then runs the main processing script
|
|
128
|
+
post '/traiter' do
|
|
129
|
+
content_type :json
|
|
130
|
+
begin
|
|
131
|
+
config = load_config
|
|
132
|
+
errors = validate_config(config)
|
|
133
|
+
|
|
134
|
+
if errors.any?
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
erreur: "Incomplete configuration",
|
|
138
|
+
details: errors
|
|
139
|
+
}.to_json
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
result = MonScript.executer(config)
|
|
143
|
+
{ success: true, resultat: result }.to_json
|
|
144
|
+
rescue => e
|
|
145
|
+
status 500
|
|
146
|
+
{ success: false, erreur: e.message }.to_json
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# :section: ROUTES — Results browsing
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
# GET /resultats — Returns the hlsv_results/ directory tree as JSON
|
|
155
|
+
get '/resultats' do
|
|
156
|
+
content_type :json
|
|
157
|
+
results_dir = 'hlsv_results'
|
|
158
|
+
|
|
159
|
+
unless Dir.exist?(results_dir)
|
|
160
|
+
return { success: true, arborescence: {} }.to_json
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
arborescence = build_tree(results_dir)
|
|
164
|
+
{ success: true, arborescence: arborescence }.to_json
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# GET /telecharger/* — Serves a result file (inline or as download)
|
|
168
|
+
# Inline for: .html, .htm, .pdf, .png, .jpg, .jpeg, .gif, .svg, .txt, .css, .js
|
|
169
|
+
# Download for: all other extensions
|
|
170
|
+
get '/telecharger/*' do
|
|
171
|
+
relative_file = params['splat'].first
|
|
172
|
+
halt 403, "Access denied" if relative_file.include?('..') || relative_file.start_with?('/')
|
|
173
|
+
|
|
174
|
+
file_path = File.join('hlsv_results', relative_file)
|
|
175
|
+
halt 404, "File not found: #{relative_file}" unless File.exist?(file_path)
|
|
176
|
+
|
|
177
|
+
extension = File.extname(file_path).downcase
|
|
178
|
+
|
|
179
|
+
if ['.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.txt'].include?(extension)
|
|
180
|
+
send_file file_path, filename: File.basename(file_path), disposition: 'inline'
|
|
181
|
+
elsif ['.css', '.js'].include?(extension)
|
|
182
|
+
content_type extension == '.css' ? 'text/css' : 'application/javascript'
|
|
183
|
+
send_file file_path, disposition: 'inline'
|
|
184
|
+
else
|
|
185
|
+
send_file file_path, filename: File.basename(file_path)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# GET /telecharger_zip_dossier/* — Packages a result folder as a ZIP archive
|
|
190
|
+
get '/telecharger_zip_dossier/*' do
|
|
191
|
+
relative_folder = params['splat'].first
|
|
192
|
+
halt 403, "Access denied" if relative_folder.include?('..') || relative_folder.start_with?('/')
|
|
193
|
+
|
|
194
|
+
folder_path = File.join('hlsv_results', relative_folder)
|
|
195
|
+
halt 404, "Folder not found" unless Dir.exist?(folder_path)
|
|
196
|
+
|
|
197
|
+
# Build ZIP in memory, preserving relative paths inside the folder
|
|
198
|
+
zip_data = Zip::OutputStream.write_buffer do |zip|
|
|
199
|
+
Dir.glob(File.join(folder_path, '**', '*')).each do |file_path|
|
|
200
|
+
next if File.directory?(file_path)
|
|
201
|
+
|
|
202
|
+
relative_path = file_path.sub("#{folder_path}/", '')
|
|
203
|
+
zip.put_next_entry(relative_path)
|
|
204
|
+
zip.write(File.read(file_path))
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
zip_data.rewind
|
|
209
|
+
content_type 'application/zip'
|
|
210
|
+
folder_name = File.basename(relative_folder)
|
|
211
|
+
attachment "#{Time.now.strftime('%Y-%m-%d')}-#{folder_name}.zip"
|
|
212
|
+
zip_data.read
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# :section: ROUTES — Excel exports
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
# GET /excel_export — Exports a single CSV file as a 2-sheet Excel workbook
|
|
220
|
+
# Sheet 1: README with metadata
|
|
221
|
+
# Sheet 2: Duplicate data with alternating row colors
|
|
222
|
+
# Params:
|
|
223
|
+
# :file — path to the CSV file
|
|
224
|
+
# :last_valid_key — comma-separated list of last tested keys (optional)
|
|
225
|
+
get '/excel_export' do
|
|
226
|
+
file = params[:file]
|
|
227
|
+
halt 403, "Access denied" if file.include?('..') || file.start_with?('/')
|
|
228
|
+
halt 404, "File not found" unless File.exist?(file)
|
|
229
|
+
|
|
230
|
+
last_valid_key = params[:last_valid_key]&.split(',') || []
|
|
231
|
+
type_dup, sheet_name = File.basename(file, '.csv').split('_')
|
|
232
|
+
csv = CSV.read(file, headers: true)
|
|
233
|
+
|
|
234
|
+
workbook = FastExcel.open(constant_memory: true)
|
|
235
|
+
|
|
236
|
+
# --- Shared formats ---
|
|
237
|
+
title_format = workbook.add_format(bold: true, font_size: 16, font_color: "#2c3e50", align: "left")
|
|
238
|
+
heading_format = workbook.add_format(bold: true, font_size: 12, font_color: "#2c3e50", bg_color: "#e8f4f8", pattern: 1, border: 1)
|
|
239
|
+
text_format = workbook.add_format(font_size: 11, align: "left", text_wrap: true)
|
|
240
|
+
footer_format = workbook.add_format(font_size: 9, font_color: "#6c757d", align: "center")
|
|
241
|
+
header_format = workbook.add_format(bold: true, bg_color: "#c0c0c0", pattern: 1, border: 1)
|
|
242
|
+
even_format = workbook.add_format(pattern: 1, border: 1)
|
|
243
|
+
odd_format = workbook.add_format(bg_color: "#E3F2FD", pattern: 1, border: 1)
|
|
244
|
+
|
|
245
|
+
# --- Sheet 1: README ---
|
|
246
|
+
readme_sheet = workbook.add_worksheet("README")
|
|
247
|
+
readme_sheet.set_column(0, 0, 80)
|
|
248
|
+
readme_sheet.set_column(1, 1, 20)
|
|
249
|
+
|
|
250
|
+
current_row = 0
|
|
251
|
+
readme_sheet.write_string(current_row, 0, "Duplicates Analysis Report - #{sheet_name}", title_format)
|
|
252
|
+
current_row += 2
|
|
253
|
+
|
|
254
|
+
readme_sheet.write_string(current_row, 0, "About This Report", heading_format)
|
|
255
|
+
current_row += 1
|
|
256
|
+
|
|
257
|
+
[
|
|
258
|
+
"This Excel file displays the detected duplicates, grouped according to the last key tested.",
|
|
259
|
+
"The duplicate groups are represented by a number in the 'No' column.",
|
|
260
|
+
"Alternating colors (white and light blue) are used to distinguish them visually.",
|
|
261
|
+
"All variables present in the dataset are displayed."
|
|
262
|
+
].each { |line| readme_sheet.write_string(current_row, 0, line, text_format); current_row += 1 }
|
|
263
|
+
current_row += 1
|
|
264
|
+
|
|
265
|
+
readme_sheet.write_string(current_row, 0, "Sheet Information", heading_format)
|
|
266
|
+
current_row += 1
|
|
267
|
+
|
|
268
|
+
[
|
|
269
|
+
"• Dataset: #{sheet_name}",
|
|
270
|
+
"• Duplicate Type: #{type_dup == 'data' ? 'Duplicates in dataset' : 'Duplicates in define.xml'}",
|
|
271
|
+
"• Total Records: #{csv.size}",
|
|
272
|
+
"• Number of Variables: #{csv.headers.size}",
|
|
273
|
+
"• Generated: #{Time.now.strftime('%Y-%m-%d at %H:%M')}",
|
|
274
|
+
"• Last key tested: #{last_valid_key.join(' ')}"
|
|
275
|
+
].each { |line| readme_sheet.write_string(current_row, 0, line, text_format); current_row += 1 }
|
|
276
|
+
current_row += 1
|
|
277
|
+
|
|
278
|
+
readme_sheet.write_string(current_row, 0, "Next Steps", heading_format)
|
|
279
|
+
current_row += 1
|
|
280
|
+
|
|
281
|
+
[
|
|
282
|
+
"→ Click on the '#{sheet_name}' tab at the bottom to view the duplicate records.",
|
|
283
|
+
"→ Identify variables to add in the configuration form to remove this duplicate.",
|
|
284
|
+
"→ Highlight issues with data cleaning."
|
|
285
|
+
].each { |line| readme_sheet.write_string(current_row, 0, line, text_format); current_row += 1 }
|
|
286
|
+
current_row += 1
|
|
287
|
+
|
|
288
|
+
readme_sheet.write_string(current_row, 0,
|
|
289
|
+
"© #{Time.now.year} AdClin. All rights reserved. | Licensed under AGPL v3",
|
|
290
|
+
footer_format)
|
|
291
|
+
|
|
292
|
+
# --- Sheet 2: Duplicate data ---
|
|
293
|
+
data_sheet = workbook.add_worksheet(sheet_name)
|
|
294
|
+
col_widths = csv.headers.map { |h| h.to_s.length }
|
|
295
|
+
|
|
296
|
+
# Write frozen header row
|
|
297
|
+
csv.headers.each_with_index { |h, col| data_sheet.write_string(0, col, h, header_format) }
|
|
298
|
+
data_sheet.freeze_panes(1, 5)
|
|
299
|
+
|
|
300
|
+
# Write data rows with alternating colors based on the 'No' column (col 0)
|
|
301
|
+
csv.each_with_index do |row, row_index|
|
|
302
|
+
fmt = row[0].to_i.even? ? even_format : odd_format
|
|
303
|
+
row.fields.each_with_index do |value, col|
|
|
304
|
+
value_str = value.to_s
|
|
305
|
+
data_sheet.write_string(row_index + 1, col, value_str, fmt)
|
|
306
|
+
col_widths[col] = [col_widths[col], value_str.length].max
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
col_widths.each_with_index { |width, col| data_sheet.set_column(col, col, width + 2) }
|
|
311
|
+
|
|
312
|
+
# Send file
|
|
313
|
+
type = type_dup == 'data' ? 'duplicate_in_dataset' : 'duplicate_in_define'
|
|
314
|
+
file_name = "#{Time.now.strftime('%Y-%m-%d')}-#{type}.xlsx"
|
|
315
|
+
content_type 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
316
|
+
attachment file_name
|
|
317
|
+
workbook.read_string
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# GET /telecharger_dossier_excel/* — Exports all CSV files in a folder as a multi-sheet Excel workbook
|
|
321
|
+
# Sheet 1: README with folder metadata
|
|
322
|
+
# One additional sheet per CSV file
|
|
323
|
+
get '/telecharger_dossier_excel/*' do
|
|
324
|
+
relative_folder = params['splat'].first
|
|
325
|
+
halt 403, "Access denied" if relative_folder.include?('..') || relative_folder.start_with?('/')
|
|
326
|
+
|
|
327
|
+
folder_path = File.join('hlsv_results', relative_folder)
|
|
328
|
+
halt 404, "Folder not found" unless Dir.exist?(folder_path)
|
|
329
|
+
|
|
330
|
+
csv_files = Dir.glob(File.join(folder_path, '*.csv')).sort
|
|
331
|
+
halt 404, "No CSV files found in this folder" if csv_files.empty?
|
|
332
|
+
|
|
333
|
+
workbook = FastExcel.open(constant_memory: true)
|
|
334
|
+
folder_name = File.dirname(relative_folder)
|
|
335
|
+
|
|
336
|
+
# --- Shared formats ---
|
|
337
|
+
title_format = workbook.add_format(bold: true, font_size: 16, font_color: "#2c3e50", align: "left")
|
|
338
|
+
heading_format = workbook.add_format(bold: true, font_size: 12, font_color: "#2c3e50", bg_color: "#e8f4f8", pattern: 1, border: 1)
|
|
339
|
+
text_format = workbook.add_format(font_size: 11, align: "left", text_wrap: true)
|
|
340
|
+
footer_format = workbook.add_format(font_size: 9, font_color: "#6c757d", align: "center")
|
|
341
|
+
header_format = workbook.add_format(bold: true, bg_color: "#c0c0c0", pattern: 1, border: 1)
|
|
342
|
+
even_format = workbook.add_format(pattern: 1, border: 1)
|
|
343
|
+
odd_format = workbook.add_format(bg_color: "#E3F2FD", pattern: 1, border: 1)
|
|
344
|
+
|
|
345
|
+
# --- Sheet 1: README ---
|
|
346
|
+
readme_sheet = workbook.add_worksheet("README")
|
|
347
|
+
readme_sheet.set_column(0, 0, 80)
|
|
348
|
+
readme_sheet.set_column(1, 1, 20)
|
|
349
|
+
|
|
350
|
+
current_row = 0
|
|
351
|
+
readme_sheet.write_string(current_row, 0, "Duplicates Analysis Report - #{folder_name}", title_format)
|
|
352
|
+
current_row += 2
|
|
353
|
+
|
|
354
|
+
readme_sheet.write_string(current_row, 0, "About This Report", heading_format)
|
|
355
|
+
current_row += 1
|
|
356
|
+
|
|
357
|
+
[
|
|
358
|
+
"This Excel file displays the detected duplicates, grouped according to the last key tested.",
|
|
359
|
+
"The duplicate groups are represented by a number in the 'No' column.",
|
|
360
|
+
"Alternating colors (white and light blue) are used to distinguish them visually.",
|
|
361
|
+
"All variables present in the dataset are displayed."
|
|
362
|
+
].each { |line| readme_sheet.write_string(current_row, 0, line, text_format); current_row += 1 }
|
|
363
|
+
current_row += 1
|
|
364
|
+
|
|
365
|
+
readme_sheet.write_string(current_row, 0, "Workbook Information", heading_format)
|
|
366
|
+
current_row += 1
|
|
367
|
+
|
|
368
|
+
[
|
|
369
|
+
"• Folder: #{folder_name}",
|
|
370
|
+
"• Number of Datasets: #{csv_files.size}",
|
|
371
|
+
"• Generated: #{Time.now.strftime('%Y-%m-%d at %H:%M')}"
|
|
372
|
+
].each { |line| readme_sheet.write_string(current_row, 0, line, text_format); current_row += 1 }
|
|
373
|
+
current_row += 1
|
|
374
|
+
|
|
375
|
+
readme_sheet.write_string(current_row, 0, "Next Steps", heading_format)
|
|
376
|
+
current_row += 1
|
|
377
|
+
|
|
378
|
+
[
|
|
379
|
+
"→ Each tab corresponds to one CSV file from the folder.",
|
|
380
|
+
"→ Review duplicate records and identify variables to add in the configuration.",
|
|
381
|
+
"→ Highlight issues with data cleaning."
|
|
382
|
+
].each { |line| readme_sheet.write_string(current_row, 0, line, text_format); current_row += 1 }
|
|
383
|
+
current_row += 1
|
|
384
|
+
|
|
385
|
+
readme_sheet.write_string(current_row, 0,
|
|
386
|
+
"© #{Time.now.year} AdClin. All rights reserved. | Licensed under AGPL v3",
|
|
387
|
+
footer_format)
|
|
388
|
+
|
|
389
|
+
# --- Data sheets: one per CSV file ---
|
|
390
|
+
csv_files.each do |csv_file|
|
|
391
|
+
sheet_name = File.basename(csv_file, '.csv')
|
|
392
|
+
sheet_name = sheet_name[0..30] if sheet_name.length > 31 # Excel sheet name limit: 31 chars
|
|
393
|
+
|
|
394
|
+
csv_data = CSV.read(csv_file, headers: true)
|
|
395
|
+
next if csv_data.empty?
|
|
396
|
+
|
|
397
|
+
data_sheet = workbook.add_worksheet(sheet_name)
|
|
398
|
+
col_widths = csv_data.headers.map { |h| h.to_s.length }
|
|
399
|
+
|
|
400
|
+
# Write frozen header row
|
|
401
|
+
csv_data.headers.each_with_index { |header, col| data_sheet.write_string(0, col, header.to_s, header_format) }
|
|
402
|
+
data_sheet.freeze_panes(1, 5)
|
|
403
|
+
|
|
404
|
+
# Write data rows with alternating colors based on the 'No' column (col 0)
|
|
405
|
+
csv_data.each_with_index do |row, row_index|
|
|
406
|
+
fmt = row[0].to_i.even? ? even_format : odd_format
|
|
407
|
+
row.fields.each_with_index do |value, col|
|
|
408
|
+
value_str = value.to_s
|
|
409
|
+
data_sheet.write_string(row_index + 1, col, value_str, fmt)
|
|
410
|
+
col_widths[col] = [col_widths[col], value_str.length].max
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
col_widths.each_with_index { |width, col| data_sheet.set_column(col, col, width + 2) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Send file
|
|
418
|
+
content_type 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
419
|
+
attachment "#{Time.now.strftime('%Y-%m-%d')}_#{folder_name}.xlsx"
|
|
420
|
+
workbook.read_string
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
# :section: ROUTES — HTML to Word conversion
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
# GET /telecharger_html_word/* — Converts an HTML result file to a .docx Word document
|
|
428
|
+
# Returns JSON with success status and generated filename
|
|
429
|
+
get '/telecharger_html_word/*' do
|
|
430
|
+
content_type :json
|
|
431
|
+
chemin_relatif = params['splat'].first
|
|
432
|
+
|
|
433
|
+
halt 403, { success: false, erreur: 'Accès refusé' }.to_json \
|
|
434
|
+
if chemin_relatif.include?('..') || chemin_relatif.start_with?('/')
|
|
435
|
+
|
|
436
|
+
chemin_absolu = File.join('hlsv_results', chemin_relatif)
|
|
437
|
+
|
|
438
|
+
halt 400, { success: false, erreur: 'Fichier HTML introuvable' }.to_json \
|
|
439
|
+
unless File.exist?(chemin_absolu) && chemin_absolu.end_with?('.html')
|
|
440
|
+
|
|
441
|
+
output_docx = chemin_absolu.sub(/\.html$/, '.docx')
|
|
442
|
+
|
|
443
|
+
puts "Parsing #{chemin_absolu}..."
|
|
444
|
+
blocks = RiReportParser.new(chemin_absolu).parse
|
|
445
|
+
puts " -> #{blocks.size} blocks extracted"
|
|
446
|
+
|
|
447
|
+
puts "Building #{output_docx}..."
|
|
448
|
+
DocxWriter.new(output_docx).write(blocks)
|
|
449
|
+
puts " -> Done: #{output_docx}"
|
|
450
|
+
|
|
451
|
+
{ success: true, message: "Word généré : #{File.basename(output_docx)}" }.to_json
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# :section: HELPERS
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
helpers do
|
|
459
|
+
|
|
460
|
+
# Recursively builds a directory tree hash for a given base path.
|
|
461
|
+
# Returns: { fichiers: [...], dossiers: { name => subtree, ... } }
|
|
462
|
+
def build_tree(base_path, relative_path = '')
|
|
463
|
+
tree = { fichiers: [], dossiers: {} }
|
|
464
|
+
full_path = relative_path.empty? ? base_path : File.join(base_path, relative_path)
|
|
465
|
+
|
|
466
|
+
return tree unless Dir.exist?(full_path)
|
|
467
|
+
|
|
468
|
+
Dir.foreach(full_path) do |entry|
|
|
469
|
+
next if entry == '.' || entry == '..'
|
|
470
|
+
|
|
471
|
+
entry_path = File.join(full_path, entry)
|
|
472
|
+
relative_entry_path = relative_path.empty? ? entry : File.join(relative_path, entry)
|
|
473
|
+
|
|
474
|
+
if File.directory?(entry_path)
|
|
475
|
+
tree[:dossiers][entry] = build_tree(base_path, relative_entry_path)
|
|
476
|
+
else
|
|
477
|
+
tree[:fichiers] << {
|
|
478
|
+
nom: entry,
|
|
479
|
+
chemin: relative_entry_path,
|
|
480
|
+
taille: File.size(entry_path),
|
|
481
|
+
date: File.mtime(entry_path).strftime('%Y-%m-%d %H:%M:%S'),
|
|
482
|
+
extension: File.extname(entry)
|
|
483
|
+
}
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
tree[:fichiers].sort_by! { |f| f[:nom] }
|
|
488
|
+
tree
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Loads and returns config.yaml as a Hash. Halts with 500 if file is missing.
|
|
492
|
+
def load_config
|
|
493
|
+
if File.exist?(Hlsv.config_path)
|
|
494
|
+
YAML.load_file(Hlsv.config_path) || {}
|
|
495
|
+
else
|
|
496
|
+
halt 500, "File config.yaml not found"
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Merges config_params into the existing config.yaml, for editable fields only.
|
|
501
|
+
# output_type is always forced to 'csv'.
|
|
502
|
+
def save_config(config_params)
|
|
503
|
+
current_config = File.exist?(Hlsv.config_path) ? (YAML.load_file(Hlsv.config_path) || {}) : {}
|
|
504
|
+
|
|
505
|
+
editable_fields = %w(
|
|
506
|
+
study_name output_directory data_directory define_path excluded_ds
|
|
507
|
+
event_key intervention_key finding_key finding_about_key
|
|
508
|
+
ds_key relrec_key CO_key TA_key TE_key TI_key TS_key TV_key
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
editable_fields.each do |field|
|
|
512
|
+
current_config[field] = config_params[field] if config_params.key?(field)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
current_config['output_type'] = 'csv'
|
|
516
|
+
File.write(Hlsv.config_path, current_config.to_yaml)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Validates the configuration hash.
|
|
520
|
+
# Checks all required fields are present and validates filesystem paths.
|
|
521
|
+
# Returns an array of error messages (empty if config is valid).
|
|
522
|
+
def validate_config(config)
|
|
523
|
+
errors = []
|
|
524
|
+
|
|
525
|
+
required_fields = {
|
|
526
|
+
'study_name' => 'Study name',
|
|
527
|
+
'output_directory' => 'Output directory',
|
|
528
|
+
'data_directory' => 'Data directory',
|
|
529
|
+
'define_path' => 'Define.xml path',
|
|
530
|
+
'event_key' => 'Event key',
|
|
531
|
+
'intervention_key' => 'Intervention key',
|
|
532
|
+
'finding_key' => 'Finding key',
|
|
533
|
+
'finding_about_key' => 'Finding about key',
|
|
534
|
+
'ds_key' => 'DS key',
|
|
535
|
+
'relrec_key' => 'RELREC key',
|
|
536
|
+
'CO_key' => 'CO key',
|
|
537
|
+
'TA_key' => 'TA key',
|
|
538
|
+
'TE_key' => 'TE key',
|
|
539
|
+
'TI_key' => 'TI key',
|
|
540
|
+
'TS_key' => 'TS key',
|
|
541
|
+
'TV_key' => 'TV key'
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
# Check all required fields are filled
|
|
545
|
+
required_fields.each do |key, name|
|
|
546
|
+
value = config[key]
|
|
547
|
+
errors << "#{name} is empty" if value.nil? || value.to_s.strip.empty?
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Check data_directory exists and contains at least one .xpt file
|
|
551
|
+
if config['data_directory'] && !config['data_directory'].to_s.strip.empty?
|
|
552
|
+
dir = config['data_directory'].gsub('\\', '/')
|
|
553
|
+
errors << "Data directory does not exist: #{dir}" unless Dir.exist?(dir)
|
|
554
|
+
errors << "Directory is empty, no .xpt files detected" if Dir["#{dir}/*"].none? { |f| File.extname(f) == '.xpt' }
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Check define_path exists (skip if value is '-', meaning no define file)
|
|
558
|
+
if config['define_path'] && config['define_path'] != '-'
|
|
559
|
+
errors << "Invalid path: #{config['define_path']}" unless File.exist?(config['define_path'])
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
config['output_type'] = 'csv'
|
|
563
|
+
errors
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
end # helpers
|
|
567
|
+
|
|
568
|
+
end # class WebApp
|
|
569
|
+
end # module Hlsv
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class SAS
|
|
2
|
+
module XPT
|
|
3
|
+
|
|
4
|
+
class Dataset
|
|
5
|
+
|
|
6
|
+
attr_reader :name
|
|
7
|
+
attr_reader :label
|
|
8
|
+
attr_reader :type
|
|
9
|
+
attr_reader :create_date
|
|
10
|
+
attr_reader :modify_date
|
|
11
|
+
attr_reader :sas_version
|
|
12
|
+
attr_reader :sas_os
|
|
13
|
+
|
|
14
|
+
# array of Variable
|
|
15
|
+
attr_reader :variables
|
|
16
|
+
|
|
17
|
+
# array of arrays of values, each array has the same number of elements at variables.size
|
|
18
|
+
attr_reader :observations
|
|
19
|
+
|
|
20
|
+
def initialize(name, label, type, create_date, modify_date, sas_version, sas_os)
|
|
21
|
+
@name = name
|
|
22
|
+
@label = label
|
|
23
|
+
@type = type
|
|
24
|
+
@create_date = create_date
|
|
25
|
+
@modify_date = modify_date
|
|
26
|
+
@sas_version = sas_version
|
|
27
|
+
@sas_os = sas_os
|
|
28
|
+
@variables = []
|
|
29
|
+
@observations = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def obs_record_length
|
|
33
|
+
variables.last.position + variables.last.length
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class SAS
|
|
2
|
+
module XPT
|
|
3
|
+
|
|
4
|
+
##
|
|
5
|
+
# All datasets in a .xpt file.
|
|
6
|
+
|
|
7
|
+
class Library
|
|
8
|
+
|
|
9
|
+
attr_reader :source_file_path
|
|
10
|
+
attr_reader :create_date
|
|
11
|
+
attr_reader :modify_date
|
|
12
|
+
attr_reader :sas_version
|
|
13
|
+
attr_reader :sas_os
|
|
14
|
+
|
|
15
|
+
attr_reader :datasets
|
|
16
|
+
|
|
17
|
+
def initialize(source_file_path, create_date, modify_date, sas_version, sas_os)
|
|
18
|
+
@source_file_path = source_file_path
|
|
19
|
+
@create_date = create_date
|
|
20
|
+
@modify_date = modify_date
|
|
21
|
+
@sas_version = sas_version
|
|
22
|
+
@sas_os = sas_os
|
|
23
|
+
@datasets = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|