hlsv 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +676 -0
- data/README.md +356 -0
- data/bin/hlsv +4 -0
- data/config.default.yaml +19 -0
- data/lib/hlsv/cli.rb +85 -0
- data/lib/hlsv/find_keys.rb +979 -0
- data/lib/hlsv/html2word.rb +602 -0
- data/lib/hlsv/mon_script.rb +169 -0
- data/lib/hlsv/version.rb +5 -0
- data/lib/hlsv/web_app.rb +569 -0
- data/lib/hlsv/xpt/dataset.rb +38 -0
- data/lib/hlsv/xpt/library.rb +28 -0
- data/lib/hlsv/xpt/reader.rb +367 -0
- data/lib/hlsv/xpt/variable.rb +130 -0
- data/lib/hlsv/xpt.rb +11 -0
- data/lib/hlsv.rb +49 -0
- data/public/Contact-LOGO.png +0 -0
- data/public/app.js +569 -0
- data/public/styles.css +586 -0
- data/public/styles_csv.css +448 -0
- data/views/csv_view.erb +85 -0
- data/views/index.erb +233 -0
- data/views/report_template.erb +1144 -0
- metadata +176 -0
|
@@ -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
|