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,367 @@
1
+
2
+ class SAS
3
+ module XPT
4
+
5
+ class Reader
6
+
7
+ # The first header record consists of the following character string, in ASCII
8
+ TOP_HEADER = "HEADER RECORD*******LIBRARY HEADER RECORD!!!!!!!000000000000000000000000000000 ".b
9
+
10
+ # The first real header record uses the following layout
11
+ # vvvvvvvv = version, oooooooo = OS, date = create date in datetime16.
12
+ # FIRST_HEADER = "SAS SAS SASLIB vvvvvvvvoooooooo ddMMMyy:hh:mm:ss"
13
+
14
+ # Second real header record
15
+ # Pad with ASCII blanks to 80 bytes
16
+ # date = modify date in datetime16.
17
+ # "ddMMMyy:hh:mm:ss"
18
+
19
+ # Member header records
20
+
21
+ # Notice the doc gives on page 4:
22
+ # "HEADER RECORD*******MEMBER HEADER RECORD!!!!!!!00000000000000000160000 0000140"
23
+ # But look at the dump on page 9, it is:
24
+ # "HEADER RECORD*******MEMBER HEADER RECORD!!!!!!!000000000000000001600000000140"
25
+
26
+ MEMBER_HEADER1 = "HEADER RECORD*******MEMBER HEADER RECORD!!!!!!!000000000000000001600000000140 ".b
27
+ MEMBER_HEADER1_VMS = "HEADER RECORD*******MEMBER HEADER RECORD!!!!!!!000000000000000001600000000136 ".b
28
+ # > Note the 0140 that appears in the member header record above. This value
29
+ # > specifies the size of the variable descriptor (NAMESTR) record that is described
30
+ # > later in this document. On the VAX/VMS operating system, the value will be 0136
31
+ # > instead of 0140. This means that the descriptor will be only 136 bytes instead
32
+ # > of 140.
33
+
34
+ MEMBER_HEADER2 = "HEADER RECORD*******DSCRPTR HEADER RECORD!!!!!!!000000000000000000000000000000 ".b
35
+
36
+ # "HEADER RECORD*******NAMESTR HEADER RECORD!!!!!!!000000xxxx00000000000000000000 "
37
+ NAMESTR_RECORD_START = "HEADER RECORD*******NAMESTR HEADER RECORD!!!!!!!000000".b
38
+ NAMESTR_RECORD_VARS_RE = /^\d{4}$/
39
+ NAMESTR_RECORD_END = "00000000000000000000 ".b
40
+
41
+ OBSERVATION_HEADER = "HEADER RECORD*******OBS HEADER RECORD!!!!!!!000000000000000000000000000000 ".b
42
+
43
+ SPECIAL_MISSING_VALUES = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ'.b
44
+
45
+ # input file path
46
+ attr_reader :xpt_path
47
+
48
+ # input encoding of string values
49
+ attr_reader :input_encoding
50
+
51
+ # output encoding for string values (default = input_encoding)
52
+ attr_reader :output_encoding
53
+
54
+ # output Library instance
55
+ attr_reader :library
56
+
57
+ def initialize(xpt_path, input_encoding: 'binary', output_encoding: nil)
58
+ @xpt_path = xpt_path
59
+ @input_encoding = input_encoding
60
+ @output_encoding = output_encoding || input_encoding
61
+ read_file
62
+ end
63
+
64
+ def read_file
65
+
66
+ File.open(xpt_path, 'rb') do |io|
67
+
68
+ # All transport data set records are 80 bytes in length.
69
+ # If there is not sufficient data to reach 80 bytes,
70
+ # then a record is padded with ASCII blanks to 80 bytes.
71
+
72
+ # top header
73
+ top_header = io.read(TOP_HEADER.length)
74
+ top_header == TOP_HEADER or issue "invalid top header"
75
+
76
+ # first_header
77
+ _sas_sas_saslib = io.read(24) # "SAS SAS SASLIB "
78
+ file_sas_version = io.read(8) # "vvvvvvvv"
79
+ file_sas_os = io.read(8) # "oooooooo"
80
+ _blanks = io.read(24)
81
+ file_create_date = io.read(16) # "ddMMMyy:hh:mm:ss"
82
+
83
+ # second header
84
+ file_modify_date = io.read(16) # "ddMMMyy:hh:mm:ss"
85
+ _blanks = io.read(64)
86
+
87
+ @library = Library.new(xpt_path, file_create_date, file_modify_date, file_sas_version, file_sas_os)
88
+
89
+ until io.eof?
90
+
91
+ # member header record 1
92
+ member_header1 = io.read(80)
93
+ case member_header1
94
+ when MEMBER_HEADER1
95
+ namestr_record_length = 140
96
+ when MEMBER_HEADER1_VMS
97
+ namestr_record_length = 136
98
+ else
99
+ issue "invalid header record 1"
100
+ namestr_record_length = 140
101
+ end
102
+
103
+ # member header record 2, constant
104
+ member_header2 = io.read(80)
105
+ member_header2 == MEMBER_HEADER2 or issue "invalid member header record 2"
106
+
107
+ # member header data
108
+ # "SAS dsname SASDATA version>os>>>>>> (24 blanks) ddMMMyy:hh:mm:ss"
109
+ _sas = io.read(8) # "SAS "
110
+ ds_name = io.read(8) # "DM "
111
+ _sasdata = io.read(8) # "SASDATA "
112
+ ds_sas_version = io.read(8) # "9.4 "
113
+ ds_sas_os = io.read(8) # "X64_SRV1"
114
+ _blanks = io.read(24) # " "
115
+ ds_create_date = io.read(16) # "26MAY21:15:57:45"
116
+
117
+ # second member header data
118
+ # "ddMMMyy:hh:mm:ss (16 padding) (40 label) (8 dstype)"
119
+
120
+ ds_modify_date = io.read(16) # "26MAY21:15:57:45"
121
+ _blanks = io.read(16) # " "
122
+ ds_label = io.read(40) # "Demographics "
123
+ ds_type = io.read(8) # " "
124
+
125
+ input_encoding != 'binary' and change_encoding ds_label
126
+ output_encoding != input_encoding and ds_label.encode!(output_encoding)
127
+
128
+ dataset = Dataset.new(ds_name.rstrip, ds_label.rstrip, ds_type.rstrip, ds_create_date, ds_modify_date, ds_sas_version.rstrip, ds_sas_os.rstrip)
129
+ @library.datasets << dataset
130
+
131
+ # Namestr header record
132
+
133
+ # In this header record, xxxx is the number of variables in the data set,
134
+ # displayed with blank-padded numeric characters. For example, for 2 variables, xxxx=0002.
135
+ # xxxx occurs at offset 54 (base 0 as in C language use).
136
+ namestr_header = io.read(80)
137
+ namestr_record_start = namestr_header[...NAMESTR_RECORD_START.length]
138
+ variable_count = namestr_header[NAMESTR_RECORD_START.length, 4]
139
+ namestr_record_end = namestr_header[-NAMESTR_RECORD_END.length..]
140
+ namestr_record_start == NAMESTR_RECORD_START or issue "invalid namestr record start", namestr_record_start, NAMESTR_RECORD_START
141
+ namestr_record_end == NAMESTR_RECORD_END or issue "invalid namestr record end", namestr_record_end, NAMESTR_RECORD_END
142
+ variable_count =~ NAMESTR_RECORD_VARS_RE or error "invalid namestr variable count #{variable_count.inspect}"
143
+ variable_count = variable_count.to_i
144
+
145
+ # Namestr records
146
+
147
+ # Each namestr field is 140 bytes long, but the fields are streamed together and broken in 80-byte pieces.
148
+ # If the last byte of the last namestr field does not fall in the last byte of the 80-byte record, the record is padded with ASCII blanks to 80 bytes.
149
+ # Here is the C structure definition for the namestr record:
150
+ #
151
+ # struct NAMESTR {
152
+ # short ntype; /* VARIABLE TYPE: 1=NUMERIC, 2=CHAR */
153
+ # short nhfun; /* HASH OF NNAME (always 0) */
154
+ # short nlng; /* LENGTH OF VARIABLE IN OBSERVATION */
155
+ # short nvar0; /* VARNUM */
156
+ # char8 nname; /* NAME OF VARIABLE */
157
+ # char40 nlabel; /* LABEL OF VARIABLE */
158
+ # char8 nform; /* NAME OF FORMAT */
159
+ # short nfl; /* FORMAT FIELD LENGTH OR 0 */
160
+ # short nfd; /* FORMAT NUMBER OF DECIMALS */
161
+ # short nfj; /* 0=LEFT JUSTIFICATION, 1=RIGHT JUST */
162
+ # char nfill[2]; /* (UNUSED, FOR ALIGNMENT AND FUTURE) */
163
+ # char8 niform; /* NAME OF INPUT FORMAT */
164
+ # short nifl; /* INFORMAT LENGTH ATTRIBUTE */
165
+ # short nifd; /* INFORMAT NUMBER OF DECIMALS */
166
+ # long npos; /* POSITION OF VALUE IN OBSERVATION */
167
+ # char rest[52]; /* remaining fields are irrelevant */
168
+ # };
169
+ #
170
+ # Note that the length given in the last 4 bytes of the member header record
171
+ # indicates the actual number of bytes for the NAMESTR structure. The size of
172
+ # the structure listed above is 140 bytes. Under VAX/VMS, the size will be 136
173
+ # bytes, meaning that the 'rest' variable may be truncated.
174
+
175
+ variable_count.times do
176
+ dataset.variables << Variable.new(io.read(namestr_record_length), input_encoding: input_encoding, output_encoding: output_encoding)
177
+ end
178
+
179
+ padding = 80 - (namestr_record_length * variable_count) % 80
180
+ if padding < 80
181
+ _padding = io.read(padding)
182
+ end
183
+
184
+ # Observation header
185
+ observation_header = io.read(80)
186
+ observation_header == OBSERVATION_HEADER or issue "invalid observation header"
187
+
188
+ # Data records
189
+ # Data records are streamed in the same way that namestrs are.
190
+ # There is ASCII blank padding at the end of the last record if necessary.
191
+ # There is no special trailing record.
192
+
193
+ # Missing Values
194
+ # Missing values are written out with the first byte (the exponent) indicating the proper missing values.
195
+ # All subsequent bytes are 0x00.
196
+ # The first byte is:
197
+ # type byte
198
+ # ._ 0x5f
199
+ # . 0x2e
200
+ # .A 0x41
201
+ # .B 0x42
202
+ # ...
203
+ # .Z 0x5a
204
+
205
+ obs_record_length = dataset.obs_record_length
206
+ record_count = 0
207
+ # by construction, the 1st obs record starts on a new 80-byte record
208
+ obs_start_pos = io.pos
209
+ # puts "obs_record_length = #{obs_record_length} obs_start_pos = #{obs_start_pos}"
210
+
211
+ blank_record = ' '.b * obs_record_length
212
+
213
+ loop do
214
+ break if io.eof?
215
+ # read_pos = io.pos
216
+ buffer = io.read(obs_record_length)
217
+ # puts "reading at #{read_pos} -> #{buffer.length} bytes #{to_hex(buffer)}"
218
+ if buffer.length != obs_record_length
219
+ # puts "#{buffer.length} != #{obs_record_length} -> #{record_count} records"
220
+ break
221
+ end
222
+ if buffer == blank_record
223
+ read_so_far = io.pos - obs_start_pos
224
+ _records_read, current_record_position = read_so_far.divmod(80)
225
+ until_end_of_record = 80 - current_record_position
226
+ if until_end_of_record > 0
227
+ buffer = io.read(until_end_of_record)
228
+ if !buffer.strip.empty?
229
+ error "non-blank bytes at end of records: #{buffer.inspect}"
230
+ end
231
+ break
232
+ end
233
+ end
234
+ record_count += 1
235
+ obs = []
236
+ dataset.variables.each do |var|
237
+ value = buffer[var.position, var.length]
238
+ if var.type == :char
239
+ # puts "char: #{value.inspect}"
240
+ value = value.rstrip
241
+ input_encoding != 'binary' and change_encoding value
242
+ output_encoding != input_encoding and value.encode!(output_encoding)
243
+ obs << value
244
+ else
245
+ # display = value.bytes.map { |b| "%02x" % b }.join
246
+ # puts "num: #{display}"
247
+ obs << ibm_to_ieee(value)
248
+ # TODO? option: ._, .A -> .Z to :_, :A, :Z or nil
249
+ # TODO? option: to_i == to_f => to_i
250
+ end
251
+ end
252
+ dataset.observations << obs
253
+ # exit
254
+ end
255
+ # puts "final pos: #{io.pos} eof: #{io.eof?}"
256
+ end
257
+ end
258
+ end
259
+
260
+ # Convert IBM-format floating point (bytes) to IEEE 754 64-bit (float).
261
+ def ibm_to_ieee(ibm_bytes)
262
+ # IBM mainframe: sign * 0.mantissa * 16 ** (exponent - 64)
263
+ # Python uses IEEE: sign * 1.mantissa * 2 ** (exponent - 1023)
264
+
265
+ # Pad-out to 8 bytes if necessary. We expect 2 to 8 bytes, but
266
+ # there's no need to check; bizarre sizes will cause a struct
267
+ # module unpack error.
268
+ if ibm_bytes.length < 8
269
+ ibm_bytes = ibm_bytes.append_as_bytes("\x00\x00\x00\x00\x00\x00\x00\x00")[...8]
270
+ end
271
+
272
+ # parse the 64 bits of IBM float as one 8-byte unsigned long long
273
+ ulong = ibm_bytes.unpack1('Q>')
274
+ # puts "ulong = #{ulong}"
275
+
276
+ # IBM: 1-bit sign, 7-bits exponent, 56-bits mantissa
277
+ sign = ulong & 0x8000000000000000
278
+ exponent = (ulong & 0x7f00000000000000) >> 56
279
+ mantissa = ulong & 0x00ffffffffffffff
280
+ # puts "sign = #{sign}"
281
+ # puts "exponent = #{exponent}"
282
+ # puts "mantissa = #{mantissa}"
283
+
284
+ if mantissa == 0
285
+ if ibm_bytes[0] == "\x00".b
286
+ return 0.0
287
+ elsif ibm_bytes[0] == "\x80".b
288
+ return -0.0
289
+ elsif ibm_bytes[0] == '.'.b
290
+ return nil
291
+ elsif SPECIAL_MISSING_VALUES.include?(ibm_bytes[0])
292
+ return :"#{ibm_bytes[0]}"
293
+ else
294
+ raise "Neither 'true' zero nor NaN: #{ibm_bytes.inspect}"
295
+ end
296
+ end
297
+
298
+ # IBM-format exponent is base 16, so the mantissa can have up to 3
299
+ # leading zero-bits in the binary mantissa. IEEE format exponent
300
+ # is base 2, so we don't need any leading zero-bits and will shift
301
+ # accordingly. This is one of the criticisms of IBM-format, its
302
+ # wobbling precision.
303
+ if (ulong & 0x0080000000000000) != 0
304
+ shift = 3
305
+ elsif (ulong & 0x0040000000000000) != 0
306
+ shift = 2
307
+ elsif (ulong & 0x0020000000000000) != 0
308
+ shift = 1
309
+ else
310
+ shift = 0
311
+ end
312
+ mantissa >>= shift
313
+ # puts "shift = #{shift}"
314
+ # puts "mantissa = #{mantissa}"
315
+
316
+ # clear the 1 bit to the left of the binary point
317
+ # this is implicit in IEEE specification
318
+ mantissa &= 0xffefffffffffffff
319
+ # puts "mantissa = #{mantissa}"
320
+
321
+ # IBM exponent is excess 64, but we subtract 65, because of the
322
+ # implicit 1 left of the radix point for the IEEE mantissa
323
+ exponent -= 65
324
+ # puts "exponent = #{exponent}"
325
+ # IBM exponent is base 16, IEEE is base 2, so we multiply by 4
326
+ exponent <<= 2
327
+ # puts "exponent = #{exponent}"
328
+ # IEEE exponent is excess 1023, but we also increment for each
329
+ # right-shift when aligning the mantissa's first 1-bit
330
+ exponent += shift + 1023
331
+ # puts "exponent = #{exponent}"
332
+
333
+ # IEEE: 1-bit sign, 11-bits exponent, 52-bits mantissa
334
+ # We didn't shift the sign bit, so it's already in the right spot
335
+ ieee = sign | (exponent << 52) | mantissa
336
+ # puts "ieee = #{ieee}"
337
+ result = [ieee].pack('Q>').unpack1('G')
338
+ # puts "result = #{result}"
339
+
340
+ result
341
+ end
342
+
343
+ def change_encoding(string)
344
+ string.force_encoding(input_encoding)
345
+ unless string.valid_encoding?
346
+ issue "invalid input encoding #{input_encoding} for #{string.inspect}"
347
+ end
348
+ end
349
+
350
+ def issue(message, actual = nil, expected = nil)
351
+ warn message
352
+ warn "- expected: #{expected.inspect}" if expected
353
+ warn "- actual: #{actual.inspect}" if actual
354
+ end
355
+
356
+ def error(message)
357
+ warn "ERROR: #{message}"
358
+ raise "cannot continue"
359
+ end
360
+
361
+ def to_hex(byte_string)
362
+ byte_string.bytes.map { |b| "%02x" % b }.join(' ')
363
+ end
364
+
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,130 @@
1
+ class SAS
2
+ module XPT
3
+
4
+ class Variable
5
+
6
+ attr_reader :name
7
+ attr_reader :label
8
+
9
+ # :numeric or :char
10
+ attr_reader :type
11
+
12
+ attr_reader :length
13
+ attr_reader :varnum
14
+
15
+ # string or nil
16
+ attr_reader :format
17
+
18
+ # :left or :right
19
+ attr_reader :format_justification
20
+
21
+ # string or nil
22
+ attr_reader :informat
23
+
24
+ # position in the observation byte record
25
+ attr_reader :position
26
+
27
+ NAMESTR_RECORD_TEMPLATE_140 = 's>x2s>2a8a40a8s>3x2a8s>2Nx52'
28
+ NAMESTR_RECORD_TEMPLATE_136 = 's>x2s>2a8a40a8s>3x2a8s>2Nx48'
29
+
30
+ def initialize(namestr_record, input_encoding:, output_encoding:)
31
+
32
+ template = namestr_record.length == 140 ? NAMESTR_RECORD_TEMPLATE_140 : NAMESTR_RECORD_TEMPLATE_136
33
+ array = namestr_record.unpack(template)
34
+
35
+ # values as read: [2, 0, 21, 1, "STUDYID ", "Study Identifier ", " ", 0, 0, 0, " ", 0, 0, 0]
36
+ ntype,
37
+ nlng,
38
+ nvar0,
39
+ nname,
40
+ nlabel,
41
+ nform,
42
+ nfl,
43
+ nfd,
44
+ nfj,
45
+ niform,
46
+ nifl,
47
+ nifd,
48
+ npos = array
49
+
50
+ # p io.read(2).unpack('s>') # short ntype; /* VARIABLE TYPE: 1=NUMERIC, 2=CHAR */
51
+ # p io.read(2).unpack('x2') # short nhfun; /* HASH OF NNAME (always 0) */
52
+ # p io.read(2).unpack('s>') # short nlng; /* LENGTH OF VARIABLE IN OBSERVATION */
53
+ # p io.read(2).unpack('s>') # short nvar0; /* VARNUM */
54
+ # p io.read(8).unpack('a8') # char8 nname; /* NAME OF VARIABLE */
55
+ # p io.read(40).unpack('a40') # char40 nlabel; /* LABEL OF VARIABLE */
56
+ # p io.read(8).unpack('a8') # char8 nform; /* NAME OF FORMAT */
57
+ # p io.read(2).unpack('s>') # short nfl; /* FORMAT FIELD LENGTH OR 0 */
58
+ # p io.read(2).unpack('s>') # short nfd; /* FORMAT NUMBER OF DECIMALS */
59
+ # p io.read(2).unpack('s>') # short nfj; /* 0=LEFT JUSTIFICATION, 1=RIGHT JUST */
60
+ # p io.read(2).unpack('x2') # char nfill[2]; /* (UNUSED, FOR ALIGNMENT AND FUTURE) */
61
+ # p io.read(8).unpack('a8') # char8 niform; /* NAME OF INPUT FORMAT */
62
+ # p io.read(2).unpack('s>') # short nifl; /* INFORMAT LENGTH ATTRIBUTE */
63
+ # p io.read(2).unpack('s>') # short nifd; /* INFORMAT NUMBER OF DECIMALS */
64
+ # p io.read(8).unpack('N') # long npos; /* POSITION OF VALUE IN OBSERVATION */
65
+ # p io.read(52).unpack('x52') # char rest[52]; /* remaining fields are irrelevant */
66
+
67
+ # puts "ntype = #{ntype.inspect} VARIABLE TYPE: 1=NUMERIC, 2=CHAR"
68
+ # puts "nlng = #{nlng.inspect} LENGTH OF VARIABLE IN OBSERVATION"
69
+ # puts "nvar0 = #{nvar0.inspect} VARNUM"
70
+ # puts "nname = #{nname.inspect} NAME OF VARIABLE"
71
+ # puts "nlabel = #{nlabel.inspect} LABEL OF VARIABLE"
72
+ # puts "nform = #{nform.inspect} NAME OF FORMAT"
73
+ # puts "nfl = #{nfl.inspect} FORMAT FIELD LENGTH OR 0"
74
+ # puts "nfd = #{nfd.inspect} FORMAT NUMBER OF DECIMALS"
75
+ # puts "nfj = #{nfj.inspect} 0=LEFT JUSTIFICATION, 1=RIGHT JUST"
76
+ # puts "niform = #{niform.inspect} NAME OF INPUT FORMAT"
77
+ # puts "nifl = #{nifl.inspect} INFORMAT LENGTH ATTRIBUTE"
78
+ # puts "nifd = #{nifd.inspect} INFORMAT NUMBER OF DECIMALS"
79
+ # puts "npos = #{npos.inspect} POSITION OF VALUE IN OBSERVATION"
80
+
81
+ errors = []
82
+
83
+ @name = nname.rstrip
84
+ @label = nlabel.rstrip
85
+ unless input_encoding != 'binary'
86
+ @label.force_encoding(input_encoding)
87
+ unless @label.valid_encoding?
88
+ warn "invalid encoding #{input_encoding} for #{@label.inspect}"
89
+ end
90
+ end
91
+ input_encoding == output_encoding or
92
+ @label.encode!(output_encoding)
93
+
94
+ case ntype
95
+ when 1 then @type = :numeric
96
+ when 2 then @type = :char
97
+ else errors << "invalid type #{ntype.inspect}"
98
+ end
99
+
100
+ @length = nlng
101
+
102
+ @varnum = nvar0
103
+
104
+ if nform.strip.empty?
105
+ @format = nil
106
+ @format_justification = nil
107
+ else
108
+ @format = "#{nform.strip}#{nfl}."
109
+ @format << nfd.to_s if nfd > 0
110
+ case nfj
111
+ when 0 then @format_justification = :left
112
+ when 1 then @format_justification = :right
113
+ else errors << "invalid justification #{nfj.inspect}"
114
+ end
115
+ end
116
+
117
+ if niform.strip.empty?
118
+ @informat = nil
119
+ else
120
+ @informat = "#{niform.strip}#{nifl}."
121
+ @informat << nifd.to_s if nifd > 0
122
+ end
123
+
124
+ @position = npos
125
+
126
+ end
127
+
128
+ end
129
+ end
130
+ end
data/lib/hlsv/xpt.rb ADDED
@@ -0,0 +1,11 @@
1
+ # Copyright (c) 2026 AdClin
2
+ # Licensed under the GNU Affero General Public License v3.0 or later.
3
+ # See the LICENSE file for details.
4
+
5
+ require_relative 'xpt/library'
6
+ require_relative 'xpt/dataset'
7
+ require_relative 'xpt/variable'
8
+ require_relative 'xpt/reader'
9
+
10
+ module Hlsv
11
+ end
data/lib/hlsv.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hlsv
4
+
5
+ class Error < StandardError; end
6
+
7
+ INSTALL_ROOT = File.expand_path("#{File.dirname(__FILE__)}/..")
8
+
9
+ def self.start_server(host: '127.0.0.1', port: 4567)
10
+ ensure_config_file
11
+ WebApp.run! host: host, port: port
12
+ end
13
+
14
+ def self.ensure_config_file
15
+ unless File.exist?(config_path)
16
+ if File.exist?(default_config_path)
17
+ config_default = YAML.load_file(default_config_path)
18
+ File.write(config_path, config_default.to_yaml)
19
+ puts "✓ config.yaml created from config.default.yaml"
20
+ else
21
+ puts "⚠ WARNING: Neither config.yaml nor config.default.yaml exists!"
22
+ end
23
+ end
24
+ end
25
+
26
+ def self.config_path
27
+ "#{Dir.pwd}/config.yaml"
28
+ end
29
+
30
+ def self.default_config_path
31
+ "#{INSTALL_ROOT}/config.default.yaml"
32
+ end
33
+
34
+ def self.license_path
35
+ "#{INSTALL_ROOT}/LICENSE"
36
+ end
37
+
38
+ end
39
+
40
+ require 'sinatra/base'
41
+
42
+ require_relative "hlsv/version"
43
+ require_relative "hlsv/find_keys"
44
+ require_relative "hlsv/html2word"
45
+ require_relative "hlsv/mon_script"
46
+ require_relative "hlsv/version"
47
+ require_relative "hlsv/web_app"
48
+ require_relative "hlsv/xpt"
49
+ require_relative "hlsv/cli"
Binary file