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.
@@ -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