hlsv 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -3
- data/README.md +36 -29
- data/lib/hlsv/analysis_registry.rb +47 -0
- data/lib/hlsv/analysis_runner.rb +193 -0
- data/lib/hlsv/config_manager.rb +125 -0
- data/lib/hlsv/html2word.rb +18 -18
- data/lib/hlsv/path_guard.rb +32 -0
- data/lib/hlsv/sdtm_validation/dataset.rb +169 -0
- data/lib/hlsv/sdtm_validation/define.rb +142 -0
- data/lib/hlsv/sdtm_validation/report.rb +357 -0
- data/lib/hlsv/sdtm_validation.rb +390 -0
- data/lib/hlsv/url_helper.rb +28 -0
- data/lib/hlsv/version.rb +1 -1
- data/lib/hlsv/web_app.rb +264 -418
- data/lib/hlsv.rb +12 -5
- data/public/css/accessibility/accessibility.css +33 -0
- data/public/css/base/layout.css +33 -0
- data/public/css/base/reset.css +23 -0
- data/public/css/base/typography.css +31 -0
- data/public/css/components/buttons.css +142 -0
- data/public/css/components/file-tree.css +107 -0
- data/public/css/components/footer.css +43 -0
- data/public/css/components/forms.css +56 -0
- data/public/css/components/header.css +52 -0
- data/public/css/components/status.css +56 -0
- data/public/css/features/csv-table.css +204 -0
- data/public/css/features/file-browser.css +208 -0
- data/public/css/responsive/responsive.css +133 -0
- data/public/css/styles.css +25 -0
- data/public/css/styles_csv.css +23 -0
- data/public/favicon.ico +0 -0
- data/public/js/analysis.js +201 -0
- data/public/js/app.js +63 -0
- data/public/js/browser.js +172 -0
- data/public/js/config.js +214 -0
- data/public/js/results.js +240 -0
- data/public/js/utils.js +57 -0
- data/views/csv_view.erb +11 -12
- data/views/index.erb +70 -19
- data/views/{report_template.erb → report.erb} +203 -188
- metadata +39 -41
- data/lib/hlsv/find_keys.rb +0 -979
- data/lib/hlsv/mon_script.rb +0 -169
- data/public/app.js +0 -569
- data/public/styles.css +0 -586
- data/public/styles_csv.css +0 -448
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
# Path-traversal guards, meant to be included into Sinatra's `helpers do ... end`
|
|
10
|
+
# block so `halt` is available.
|
|
11
|
+
module PathGuard
|
|
12
|
+
# Basic anti-traversal check for a user-supplied relative path.
|
|
13
|
+
# Use this when the target base directory varies (e.g. a user-configured
|
|
14
|
+
# data_directory) and can't be constrained to a single root.
|
|
15
|
+
def safe_relative_path!(relative, label: "file")
|
|
16
|
+
halt 400, "Missing #{label} parameter" if relative.nil? || relative.to_s.empty?
|
|
17
|
+
halt 403, "Access denied" if relative.include?('..') || relative.start_with?('/')
|
|
18
|
+
relative
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Stronger check: resolves the path and ensures it stays within base_dir.
|
|
22
|
+
# Use this for anything rooted under a known folder (e.g. hlsv_results/).
|
|
23
|
+
def resolve_within!(base_dir, relative)
|
|
24
|
+
safe_relative_path!(relative)
|
|
25
|
+
|
|
26
|
+
base = File.expand_path(base_dir)
|
|
27
|
+
full = File.expand_path(File.join(base, relative))
|
|
28
|
+
halt 403, "Access denied" unless full.start_with?("#{base}#{File::SEPARATOR}")
|
|
29
|
+
full
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
module Hlsv
|
|
2
|
+
class SdtmValidation
|
|
3
|
+
class Dataset
|
|
4
|
+
|
|
5
|
+
def self.from_xpt(xpt_path)
|
|
6
|
+
|
|
7
|
+
reader = SAS::XPT::Reader.new(xpt_path)
|
|
8
|
+
|
|
9
|
+
# only the fist dataset is loaded
|
|
10
|
+
puts "several datasets in one xpt, only the first one is kept" if reader.library.datasets.size > 1
|
|
11
|
+
dataset = reader.library.datasets.first
|
|
12
|
+
|
|
13
|
+
# create records as hash :variable => value
|
|
14
|
+
vars = dataset.variables.map(&:name).map(&:to_sym)
|
|
15
|
+
obs = dataset.observations
|
|
16
|
+
|
|
17
|
+
records = obs.map do |values|
|
|
18
|
+
vars.zip(values).to_h
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Dataset.new(dataset.name, dataset.label, records)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :name
|
|
25
|
+
attr_reader :label
|
|
26
|
+
attr_reader :records
|
|
27
|
+
|
|
28
|
+
attr_accessor :non_ascii_values
|
|
29
|
+
|
|
30
|
+
attr_accessor :define_status
|
|
31
|
+
attr_accessor :define_keys
|
|
32
|
+
attr_accessor :define_duplicate_records
|
|
33
|
+
attr_accessor :define_duplicate_file
|
|
34
|
+
|
|
35
|
+
attr_accessor :tested_key
|
|
36
|
+
attr_accessor :minimal_key_status
|
|
37
|
+
attr_accessor :minimal_key
|
|
38
|
+
attr_accessor :data_duplicate_records
|
|
39
|
+
attr_accessor :data_duplicate_file
|
|
40
|
+
|
|
41
|
+
def initialize(name, label, records)
|
|
42
|
+
|
|
43
|
+
@name = name
|
|
44
|
+
@label = label
|
|
45
|
+
@records = records
|
|
46
|
+
|
|
47
|
+
@non_ascii_values = nil
|
|
48
|
+
@define_status = nil
|
|
49
|
+
@define_keys = nil
|
|
50
|
+
@define_duplicate_records = nil
|
|
51
|
+
@define_duplicate_file = nil
|
|
52
|
+
|
|
53
|
+
@tested_key = nil
|
|
54
|
+
@minimal_key = []
|
|
55
|
+
@minimal_key_status = []
|
|
56
|
+
@data_duplicate_records = []
|
|
57
|
+
@data_duplicate_file = []
|
|
58
|
+
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def candidates_keys(ds, config)
|
|
62
|
+
@candidates_keys ||= begin
|
|
63
|
+
keys = candidates(ds, config)
|
|
64
|
+
keys.first.is_a?(Array) ? keys : [keys]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private def candidates(ds, config)
|
|
69
|
+
|
|
70
|
+
keys = [:USUBJID]
|
|
71
|
+
|
|
72
|
+
# get the variable from the config file according to the type of dataset
|
|
73
|
+
case type(ds)
|
|
74
|
+
when :intervention, :event, :finding, :finding_about
|
|
75
|
+
keys.concat(config["#{@type}_key"]&.split&.map { |v| (%w[STUDYID DOMAIN SPDEVID POOLID VISITNUM VISIT TAETORD SUBJID].include?(v) ? v : "#{ds}#{v}").to_sym } || [])
|
|
76
|
+
when :trial_design
|
|
77
|
+
keys.concat(config["#{ds}_key"]&.split || [])
|
|
78
|
+
when :relation
|
|
79
|
+
keys.concat(config['RELREC_key']&.split || [])
|
|
80
|
+
when :supp
|
|
81
|
+
keys = %w(USUBJID IDVAR IDVARVAL QNAM).map(&:to_sym)
|
|
82
|
+
when :special
|
|
83
|
+
if ds == 'CO'
|
|
84
|
+
keys.concat(config['CO_key']&.split || [])
|
|
85
|
+
elsif ds == 'DS'
|
|
86
|
+
keys.concat(config['DS_key']&.split || [])
|
|
87
|
+
elsif %w(DM DC).include? ds
|
|
88
|
+
keys = [:USUBJID, :SUBJID]
|
|
89
|
+
elsif ds == 'SE'
|
|
90
|
+
keys = [[:USUBJID, :EPOCH, :SUBJID], [:USUBJID, :TAETORD, :SUBJID]]
|
|
91
|
+
elsif ds == 'SV'
|
|
92
|
+
keys = [[:USUBJID, :SVSTDTC, :SUBJID], [:USUBJID, :VISITNUM, :SUBJID]]
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
puts "/!\\ No candidates keys: #{@type}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
puts "/!\\ No candidates keys: #{@type}" if keys == [:USUBJID]
|
|
99
|
+
@tested_key = keys.first.is_a?(Array) ? keys : [keys]
|
|
100
|
+
|
|
101
|
+
return keys
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
INTERVENTION = %w(AG CM EC EX ML PR SU)
|
|
105
|
+
EVENT = %w(AE BE CE DV HO MH)
|
|
106
|
+
FINDING = %w(BS CP GF IS LB MB MI MS PC PP CV MK NV OE RE RP UR GI IG FT QS RS DA DD EG IE PE SC
|
|
107
|
+
SS TR TU VS)
|
|
108
|
+
FINDING_ABOUT = %w(FA SR)
|
|
109
|
+
TRIAL_DESIGN = %w(TA TD TE TI TM TS TV TX)
|
|
110
|
+
RELATION = %w(RELREC RELSPEC RELSUB)
|
|
111
|
+
SPECIAL = %w(CO DM SE SV DS)
|
|
112
|
+
|
|
113
|
+
def type(ds)
|
|
114
|
+
|
|
115
|
+
@type ||= case ds
|
|
116
|
+
when *INTERVENTION
|
|
117
|
+
:intervention
|
|
118
|
+
when *EVENT
|
|
119
|
+
:event
|
|
120
|
+
when *FINDING
|
|
121
|
+
:finding
|
|
122
|
+
when *FINDING_ABOUT
|
|
123
|
+
:finding_about
|
|
124
|
+
when *TRIAL_DESIGN
|
|
125
|
+
:trial_design
|
|
126
|
+
when *RELATION
|
|
127
|
+
:relation
|
|
128
|
+
when *SPECIAL
|
|
129
|
+
:special
|
|
130
|
+
when /\ASUPP/
|
|
131
|
+
:supp
|
|
132
|
+
else
|
|
133
|
+
puts "/!\\ Unexpected dataset: #{ds}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def type_to_s(ds)
|
|
138
|
+
@type_to_s ||= case type(ds)
|
|
139
|
+
when :intervention, :event, :finding, :finding_about
|
|
140
|
+
"General Oberservation, #{@type} dataset"
|
|
141
|
+
when :relation
|
|
142
|
+
"Relationship Dataset, #{ds}"
|
|
143
|
+
when :trial_design
|
|
144
|
+
"Trial Design, #{ds}"
|
|
145
|
+
when :special
|
|
146
|
+
"Special Dataset, #{ds}"
|
|
147
|
+
when :supp
|
|
148
|
+
"Supp Dataset"
|
|
149
|
+
else
|
|
150
|
+
puts "/!\\ Unexpected dataset: #{ds}, type: #{@type}"
|
|
151
|
+
|
|
152
|
+
"Undefined"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# when some non ascii charactere have been found, then status is false
|
|
157
|
+
# otherwiese status is true
|
|
158
|
+
def ascii_status
|
|
159
|
+
if non_ascii_values.nil?
|
|
160
|
+
nil
|
|
161
|
+
elsif non_ascii_values.empty?
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module Hlsv
|
|
2
|
+
class SdtmValidation
|
|
3
|
+
class Define
|
|
4
|
+
|
|
5
|
+
attr_reader :path # String
|
|
6
|
+
attr_reader :status # true/false/nil
|
|
7
|
+
attr_reader :keys # Hash dataset: variables (Array of String)
|
|
8
|
+
attr_reader :exception # Array of String
|
|
9
|
+
|
|
10
|
+
def initialize(infile)
|
|
11
|
+
@exception = []
|
|
12
|
+
|
|
13
|
+
@path = check_path(infile)
|
|
14
|
+
|
|
15
|
+
node, @status = load_define
|
|
16
|
+
|
|
17
|
+
@keys = get_define_keys(node)
|
|
18
|
+
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_path(path)
|
|
22
|
+
|
|
23
|
+
if path.nil? || path == '-'
|
|
24
|
+
path
|
|
25
|
+
elsif File.basename(path) == 'define.xml'
|
|
26
|
+
path
|
|
27
|
+
else
|
|
28
|
+
|
|
29
|
+
if File.directory? path
|
|
30
|
+
@exception << "The path isn't complete, it's a folder."
|
|
31
|
+
@exception << "To find the file, add 'define.xml' to the folder in the config (already done here)."
|
|
32
|
+
puts 'The define path is not complete.'
|
|
33
|
+
|
|
34
|
+
"#{path}/define.xml"
|
|
35
|
+
elsif File.exist? path
|
|
36
|
+
path
|
|
37
|
+
else
|
|
38
|
+
@exception << "The file cannot be read: #{path.inspect}"
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# load define from the config information
|
|
46
|
+
# return Nokogiri::Node
|
|
47
|
+
def load_define
|
|
48
|
+
|
|
49
|
+
# no define specify: define analyse part, not run with error
|
|
50
|
+
if @path.nil?
|
|
51
|
+
puts "no define specify"
|
|
52
|
+
node = nil
|
|
53
|
+
status = nil
|
|
54
|
+
|
|
55
|
+
# no define expected: define analyse part, not run
|
|
56
|
+
elsif @path == '-'
|
|
57
|
+
puts "no define expected"
|
|
58
|
+
node = '-'
|
|
59
|
+
status = false
|
|
60
|
+
|
|
61
|
+
# define present
|
|
62
|
+
else
|
|
63
|
+
begin
|
|
64
|
+
node = File.open(@path, "rb") { |io| Nokogiri::XML(io, &:noblanks) }
|
|
65
|
+
status = true
|
|
66
|
+
puts "define.xml loaded"
|
|
67
|
+
|
|
68
|
+
rescue => e
|
|
69
|
+
puts "⚠ Error loading define.xml : #{e.message}"
|
|
70
|
+
node = nil
|
|
71
|
+
status = nil
|
|
72
|
+
@exception << 'Invalid path, update the config'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[node, status]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def datasets
|
|
80
|
+
if keys.nil? || keys == '-'
|
|
81
|
+
[]
|
|
82
|
+
else
|
|
83
|
+
keys.keys
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def define_key_for(ds)
|
|
88
|
+
if keys.nil? || keys == '-'
|
|
89
|
+
[]
|
|
90
|
+
else
|
|
91
|
+
keys[ds]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# get the define key variables by dataset from a nokogiri node,
|
|
96
|
+
# return an hash of dataset with list of variables name
|
|
97
|
+
# - keys order is not supported
|
|
98
|
+
private def get_define_keys(define_node)
|
|
99
|
+
# shortcut if no define
|
|
100
|
+
return nil if define_node.nil?
|
|
101
|
+
# shortcut if define = -
|
|
102
|
+
return '-' if define_node == '-'
|
|
103
|
+
# store all ItemGroup
|
|
104
|
+
item_group = define_node.css("ItemGroupDef")
|
|
105
|
+
# store all Item
|
|
106
|
+
item_def = define_node.css("ItemDef").group_by { |id| id['OID'] }
|
|
107
|
+
|
|
108
|
+
# keep only the dataset name & the key variables
|
|
109
|
+
define_keys = {}
|
|
110
|
+
# loop on all item group (dataset)
|
|
111
|
+
item_group.each do |ig|
|
|
112
|
+
key_list = []
|
|
113
|
+
ds = ig["Name"].upcase
|
|
114
|
+
# loop on all item (variables)
|
|
115
|
+
ig.css("ItemRef").each do |ir|
|
|
116
|
+
# exclude all variables not in the key
|
|
117
|
+
index = ir["KeySequence"]&.to_i
|
|
118
|
+
next if index.nil?
|
|
119
|
+
# get the ItemDef of the variable by the ItemRef OID
|
|
120
|
+
item = item_def[ir["ItemOID"]]
|
|
121
|
+
# expect one item per variable
|
|
122
|
+
unless item.size == 1
|
|
123
|
+
puts "several item with the same OID: #{item.size}"
|
|
124
|
+
item.each do |i|
|
|
125
|
+
puts i
|
|
126
|
+
end
|
|
127
|
+
puts "only the first is kept"
|
|
128
|
+
end
|
|
129
|
+
# store the variable name
|
|
130
|
+
key_list << item.first["Name"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# assign the key to the dataset
|
|
134
|
+
define_keys[ds] = key_list
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
define_keys
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'erb'
|
|
3
|
+
require 'fast_excel'
|
|
4
|
+
|
|
5
|
+
require_relative '../url_helper'
|
|
6
|
+
|
|
7
|
+
module Hlsv
|
|
8
|
+
class SdtmValidation
|
|
9
|
+
class Report
|
|
10
|
+
|
|
11
|
+
include Hlsv::UrlHelper
|
|
12
|
+
|
|
13
|
+
attr_reader :study_name
|
|
14
|
+
attr_reader :ds_path
|
|
15
|
+
attr_reader :datasets
|
|
16
|
+
attr_reader :define
|
|
17
|
+
attr_reader :excluded_dataset
|
|
18
|
+
|
|
19
|
+
attr_reader :report_name
|
|
20
|
+
|
|
21
|
+
REPORT_SUFFIX = '_high_level_check'
|
|
22
|
+
|
|
23
|
+
# Single source of truth for the report base path, so callers (web_app.rb)
|
|
24
|
+
# can check whether a report already exists on disk without instantiating
|
|
25
|
+
# a full Report (which requires datasets/define in memory)
|
|
26
|
+
def self.report_name_for(study_name)
|
|
27
|
+
"#{study_name}/#{File.basename(study_name).gsub(' ', '_').downcase}#{REPORT_SUFFIX}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Builds a standalone one-sheet Excel workbook for a single duplicate CSV
|
|
31
|
+
# file already on disk - used by the "Export to Excel" button on
|
|
32
|
+
# /csv_view. Unlike excel_export/excel_export_folder, this never needs the
|
|
33
|
+
# analysis in memory: everything required (the CSV path, the dataset name,
|
|
34
|
+
# the tested key) is already carried by the static HTML report's query
|
|
35
|
+
# string, so this works even after a server restart.
|
|
36
|
+
def self.build_single_csv_workbook(study_name:, ds_name:, csv_path:, key: nil)
|
|
37
|
+
report = allocate
|
|
38
|
+
report.instance_variable_set(:@study_name, study_name)
|
|
39
|
+
report.send(:build_single_csv_workbook_body, ds_name: ds_name, csv_path: csv_path, key: key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(study_name, ds_path, datasets, define, excluded_dataset, web_mode: false)
|
|
43
|
+
|
|
44
|
+
@web_mode = web_mode
|
|
45
|
+
@study_name = study_name
|
|
46
|
+
@ds_path = ds_path
|
|
47
|
+
@datasets = datasets
|
|
48
|
+
@define = define
|
|
49
|
+
@excluded_dataset = excluded_dataset
|
|
50
|
+
|
|
51
|
+
@report_name = self.class.report_name_for(@study_name)
|
|
52
|
+
|
|
53
|
+
puts "\n=== Report"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# dynamic title for acces web or manual
|
|
57
|
+
def title
|
|
58
|
+
if @study_name.include?('/')
|
|
59
|
+
File.basename(@study_name)
|
|
60
|
+
else
|
|
61
|
+
@study_name
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def config_file_path
|
|
66
|
+
"#{study_name}/config.yaml"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def relative_report_path(file)
|
|
70
|
+
file.sub("#{@study_name}/", '')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
###
|
|
74
|
+
# HTML section
|
|
75
|
+
###
|
|
76
|
+
|
|
77
|
+
# html build from ERB file
|
|
78
|
+
def generate_html
|
|
79
|
+
# Charge ERB template
|
|
80
|
+
# template_path = File.expand_path('../../../../views/report_template.erb', __FILE__)
|
|
81
|
+
template_path = File.expand_path('../../../../views/report.erb', __FILE__)
|
|
82
|
+
|
|
83
|
+
unless File.exist?(template_path)
|
|
84
|
+
raise "Template file not found: #{template_path}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Encode logo in base64
|
|
88
|
+
logo_path = File.expand_path('../../../../public/Contact-LOGO.png', __FILE__)
|
|
89
|
+
@logo_base64 = if File.exist?(logo_path)
|
|
90
|
+
require 'base64'
|
|
91
|
+
"data:image/png;base64,#{Base64.strict_encode64(File.binread(logo_path))}"
|
|
92
|
+
else
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Create HTML
|
|
97
|
+
template = File.read(template_path)
|
|
98
|
+
erb = ERB.new(template, trim_mode: '-')
|
|
99
|
+
erb.result(binding)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
###
|
|
103
|
+
# Word section
|
|
104
|
+
###
|
|
105
|
+
|
|
106
|
+
# write html file
|
|
107
|
+
def html
|
|
108
|
+
output_path = "#{@report_name}.html"
|
|
109
|
+
html_content = generate_html
|
|
110
|
+
|
|
111
|
+
File.write(output_path, html_content)
|
|
112
|
+
puts "HTML report generated: #{output_path}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# write docx file
|
|
116
|
+
def docx
|
|
117
|
+
html_path = "#{@report_name}.html"
|
|
118
|
+
docx_path = "#{@report_name}.docx"
|
|
119
|
+
|
|
120
|
+
# generate html if not already done
|
|
121
|
+
html unless File.exist?(html_path)
|
|
122
|
+
|
|
123
|
+
blocks = RiReportParser.new(html_path).parse
|
|
124
|
+
DocxWriter.new(docx_path).write(blocks)
|
|
125
|
+
puts "Word report generated: #{docx_path}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
###
|
|
129
|
+
# Excel section
|
|
130
|
+
###
|
|
131
|
+
def excel(dataset: nil)
|
|
132
|
+
output_path = dataset \
|
|
133
|
+
? "#{File.dirname(@report_name)}/#{dataset}.xlsx"
|
|
134
|
+
: "#{@report_name}.xlsx"
|
|
135
|
+
|
|
136
|
+
kill_excel_holding(output_path)
|
|
137
|
+
wait_for_file_release(output_path)
|
|
138
|
+
|
|
139
|
+
workbook = dataset ? excel_export(dataset) : excel_export_folder
|
|
140
|
+
return unless workbook # cas "no duplicates" dans excel_export
|
|
141
|
+
|
|
142
|
+
File.binwrite(output_path, workbook.read_string)
|
|
143
|
+
puts "Excel report generated: #{output_path}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def excel_export(dataset)
|
|
147
|
+
ds = datasets.fetch(dataset) { raise Hlsv::Error, "Excel report: dataset not found, #{dataset.inspect}" }
|
|
148
|
+
|
|
149
|
+
if ds.data_duplicate_file.empty? && ds.define_duplicate_file.to_s.empty?
|
|
150
|
+
puts "Excel report: no duplicates present in #{dataset}, no excel file generated"
|
|
151
|
+
return nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
build_workbook([ds])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def excel_export_folder
|
|
158
|
+
ds_with_issues = @datasets.values.select { |ds|
|
|
159
|
+
ds.data_duplicate_file.any? || !ds.define_duplicate_file.to_s.empty?
|
|
160
|
+
}
|
|
161
|
+
build_workbook(ds_with_issues, extra_info: ["• Number of Datasets: #{ds_with_issues.size}"])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def build_single_csv_workbook_body(ds_name:, csv_path:, key:)
|
|
167
|
+
workbook = FastExcel.open(constant_memory: true)
|
|
168
|
+
formats = build_formats(workbook)
|
|
169
|
+
|
|
170
|
+
data_keys = key ? [[ds_name, key.split(',').map(&:strip)]] : []
|
|
171
|
+
|
|
172
|
+
add_readme_sheet(workbook, formats,
|
|
173
|
+
title: "Duplicates Analysis Report - #{@study_name}",
|
|
174
|
+
info: ["• Dataset: #{ds_name}", "• Generated: #{Time.now.strftime('%Y-%m-%d at %H:%M')}"],
|
|
175
|
+
keys: [data_keys, []],
|
|
176
|
+
next_steps: [
|
|
177
|
+
"→ Review duplicate records and identify variables to add in the configuration.",
|
|
178
|
+
"→ Highlight issues with data cleaning."
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
csv = CSV.read(csv_path, headers: true)
|
|
183
|
+
add_data_sheet(workbook, formats, File.basename(csv_path, '.csv')[0..30], csv) unless csv.empty?
|
|
184
|
+
|
|
185
|
+
workbook
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def extract_data_keys(ds_list)
|
|
189
|
+
ds_list.flat_map do |ds|
|
|
190
|
+
if ds.minimal_key.size == 1
|
|
191
|
+
[[ds.name, ds.minimal_key.first]]
|
|
192
|
+
else
|
|
193
|
+
ds.minimal_key.each_with_index.map do |key, i|
|
|
194
|
+
["#{ds.name} (option #{i + 1})", key]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def extract_define_keys(ds_list)
|
|
201
|
+
ds_list.filter_map { |ds| [ds.name, ds.define_keys] if !ds.define_status && !ds.define_keys.nil? }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def collect_csv_files(ds_list)
|
|
205
|
+
ds_list.flat_map { |ds| ds.data_duplicate_file + [ds.define_duplicate_file] }.compact
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def build_workbook(ds_list, extra_info: [])
|
|
209
|
+
data_keys = extract_data_keys(ds_list)
|
|
210
|
+
define_keys = extract_define_keys(ds_list)
|
|
211
|
+
csv_files = collect_csv_files(ds_list)
|
|
212
|
+
|
|
213
|
+
workbook = FastExcel.open(constant_memory: true)
|
|
214
|
+
formats = build_formats(workbook)
|
|
215
|
+
|
|
216
|
+
add_readme_sheet(workbook, formats,
|
|
217
|
+
title: "Duplicates Analysis Report - #{@study_name}",
|
|
218
|
+
info: ["• Folder: #{@study_name}", *extra_info, "• Generated: #{Time.now.strftime('%Y-%m-%d at %H:%M')}"],
|
|
219
|
+
keys: [data_keys, define_keys],
|
|
220
|
+
next_steps: [
|
|
221
|
+
"→ Each tab corresponds to one CSV file from the folder.",
|
|
222
|
+
"→ Review duplicate records and identify variables to add in the configuration.",
|
|
223
|
+
"→ Highlight issues with data cleaning."
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
csv_files.each do |csv_file|
|
|
228
|
+
sheet_name = File.basename(csv_file, '.csv')[0..30]
|
|
229
|
+
csv = CSV.read(csv_file, headers: true)
|
|
230
|
+
next if csv.empty?
|
|
231
|
+
add_data_sheet(workbook, formats, sheet_name, csv)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
workbook
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def kill_excel_holding(filepath)
|
|
238
|
+
return unless Gem.win_platform?
|
|
239
|
+
|
|
240
|
+
filename = File.basename(filepath, '.*').downcase
|
|
241
|
+
|
|
242
|
+
ps_command = "Get-Process excel -ErrorAction SilentlyContinue | " \
|
|
243
|
+
"Where-Object { $_.MainWindowTitle -like '*#{filename}*' }"
|
|
244
|
+
|
|
245
|
+
result = `powershell -NoProfile -Command "#{ps_command}"`.strip
|
|
246
|
+
|
|
247
|
+
if result.empty?
|
|
248
|
+
# Excel not running or file not open in Excel
|
|
249
|
+
return
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
`powershell -NoProfile -Command "Stop-Process -Name excel -Force -ErrorAction SilentlyContinue"`
|
|
253
|
+
puts "Excel closed (#{filepath} was open)"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def wait_for_file_release(filepath, max_attempts: 10, wait: 1)
|
|
257
|
+
return unless File.exist?(filepath)
|
|
258
|
+
|
|
259
|
+
max_attempts.times do |i|
|
|
260
|
+
begin
|
|
261
|
+
File.open(filepath, "r+b") { }
|
|
262
|
+
return # File is released, we can proceed
|
|
263
|
+
rescue Errno::EACCES, Errno::EBUSY
|
|
264
|
+
puts "Waiting for file release... (#{i + 1}/#{max_attempts})"
|
|
265
|
+
sleep wait
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
raise "Could not access file after #{max_attempts} attempts: #{filepath}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def build_formats(workbook)
|
|
273
|
+
{
|
|
274
|
+
title: workbook.add_format(bold: true, font_size: 16, font_color: "#2c3e50", align: "left"),
|
|
275
|
+
heading: workbook.add_format(bold: true, font_size: 12, font_color: "#2c3e50", bg_color: "#e8f4f8", pattern: 1, border: 1),
|
|
276
|
+
text: workbook.add_format(font_size: 11, align: "left", text_wrap: true),
|
|
277
|
+
footer: workbook.add_format(font_size: 9, font_color: "#6c757d", align: "center"),
|
|
278
|
+
header: workbook.add_format(bold: true, bg_color: "#c0c0c0", pattern: 1, border: 1),
|
|
279
|
+
even: workbook.add_format(bg_color: "#FFFFFF", pattern: 1, border: 1),
|
|
280
|
+
odd: workbook.add_format(bg_color: "#E3F2FD", pattern: 1, border: 1)
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def add_readme_sheet(workbook, formats, title:, info:, keys:, next_steps:)
|
|
285
|
+
sheet = workbook.add_worksheet("README")
|
|
286
|
+
sheet.set_column(0, 0, 80)
|
|
287
|
+
sheet.set_column(1, 1, 20)
|
|
288
|
+
|
|
289
|
+
row = 0
|
|
290
|
+
sheet.write_string(row, 0, title, formats[:title])
|
|
291
|
+
row += 2
|
|
292
|
+
|
|
293
|
+
sheet.write_string(row, 0, "About This Report", formats[:heading])
|
|
294
|
+
row += 1
|
|
295
|
+
[
|
|
296
|
+
"This Excel file displays the detected duplicates, grouped according to the last key tested.",
|
|
297
|
+
"The duplicate groups are represented by a number in the 'No' column.",
|
|
298
|
+
"Alternating colors (white and light blue) are used to distinguish them visually.",
|
|
299
|
+
"All variables present in the dataset are displayed."
|
|
300
|
+
].each { |line| sheet.write_string(row, 0, line, formats[:text]); row += 1 }
|
|
301
|
+
row += 1
|
|
302
|
+
|
|
303
|
+
sheet.write_string(row, 0, "Sheet Information", formats[:heading])
|
|
304
|
+
row += 1
|
|
305
|
+
info.each { |line| sheet.write_string(row, 0, line, formats[:text]); row += 1 }
|
|
306
|
+
row += 1
|
|
307
|
+
sheet.write_string(row, 0, "Keys Information", formats[:heading])
|
|
308
|
+
row += 1
|
|
309
|
+
unless keys[0].empty?
|
|
310
|
+
sheet.write_string(row, 0, "data", formats[:text]); row += 1
|
|
311
|
+
keys[0].each do |ds, vars|
|
|
312
|
+
next if vars.nil?
|
|
313
|
+
sheet.write_string(row, 0, "• #{ds}: #{vars.join(', ')}", formats[:text])
|
|
314
|
+
row += 1
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
unless keys[1].empty?
|
|
318
|
+
sheet.write_string(row, 0, "define", formats[:text]); row += 1
|
|
319
|
+
keys[1].each do |ds, vars|
|
|
320
|
+
next if vars.nil?
|
|
321
|
+
sheet.write_string(row, 0, "• #{ds}: #{vars.join(', ')}", formats[:text])
|
|
322
|
+
row += 1
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
row += 1
|
|
326
|
+
sheet.write_string(row, 0, "Next Steps", formats[:heading])
|
|
327
|
+
row += 1
|
|
328
|
+
next_steps.each { |line| sheet.write_string(row, 0, line, formats[:text]); row += 1 }
|
|
329
|
+
row += 1
|
|
330
|
+
|
|
331
|
+
sheet.write_string(row, 0,
|
|
332
|
+
"© #{Time.now.year} AdClin. All rights reserved. | Licensed under AGPL v3",
|
|
333
|
+
formats[:footer])
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def add_data_sheet(workbook, formats, sheet_name, csv)
|
|
337
|
+
sheet = workbook.add_worksheet(sheet_name)
|
|
338
|
+
col_widths = csv.headers.map { |h| h.to_s.length }
|
|
339
|
+
|
|
340
|
+
csv.headers.each_with_index { |h, col| sheet.write_string(0, col, h.to_s, formats[:header]) }
|
|
341
|
+
sheet.freeze_panes(1, 5)
|
|
342
|
+
|
|
343
|
+
csv.each_with_index do |row, row_index|
|
|
344
|
+
fmt = row[0].to_i.even? ? formats[:even] : formats[:odd]
|
|
345
|
+
row.fields.each_with_index do |value, col|
|
|
346
|
+
value_str = value.to_s
|
|
347
|
+
sheet.write_string(row_index + 1, col, value_str, fmt)
|
|
348
|
+
col_widths[col] = [col_widths[col], value_str.length].max
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
col_widths.each_with_index { |width, col| sheet.set_column(col, col, width + 2) }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|