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