bisac 0.8

Sign up to get free protection for your applications and to get access to all the features.
data/lib/bisac.rb ADDED
@@ -0,0 +1,17 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+
3
+ # load other files from within this lib
4
+ require 'bisac/message'
5
+ require 'bisac/product'
6
+ require 'bisac/po'
7
+ require 'bisac/po_line_item'
8
+ require 'bisac/poa'
9
+ require 'bisac/poa_line_item'
10
+
11
+ # require rubygems
12
+ require 'rbook/isbn'
13
+
14
+ # Ruby module for reading and writing BISAC file formats.
15
+ module Bisac
16
+ class InvalidFileError < RuntimeError; end
17
+ end
@@ -0,0 +1,110 @@
1
+ module Bisac
2
+
3
+ # Represents a single BISAC product metadata file. Note that the BISAC metadata
4
+ # file format is now quite dated, and should be avoided where possible, as it
5
+ # doesn't support the ISBN13 standard.
6
+ #
7
+ # = Generating
8
+ #
9
+ # msg = Bisac::Message.new("Company Name", "1111111", "080906", 1)
10
+ # msg << Bisac:Product.new("0385519869")
11
+ # msg << Bisac:Product.new("014044565X")
12
+ # msg.write("filename.bsc")
13
+ #
14
+ # = Reading
15
+ #
16
+ # msg = Bisac::Message.load("filename.bsc")
17
+ # puts msg.company
18
+ # puts msg.san
19
+ # puts msg.products.size
20
+ class Message
21
+
22
+ attr_accessor :company, :san, :batch, :code, :products
23
+
24
+ # creates a new bisac file in memory
25
+ # params:
26
+ # - company = company name (10 chars)
27
+ # - san = company SAN number (12 chars)
28
+ # - batch = batch name for this file (6 chars)
29
+ # - code = 1 for new titles, 2 for updated titles (1 char)
30
+ def initialize(company, san, batch, code)
31
+ @company = company
32
+ @san = san
33
+ @batch = batch
34
+ @code = code
35
+ @products = []
36
+ end
37
+
38
+ # loads the requested BISAC file into memory.
39
+ # returns a Message object or nil if the file is not
40
+ # a BISAC file
41
+ def self.load(filename)
42
+ msg = self.new(nil,nil,nil,nil)
43
+
44
+ File.open(filename, "r") do |f|
45
+ f.each_line do |l|
46
+ if l[0,10].eql?("**HEADER**")
47
+ msg.company = l[16,10].strip
48
+ msg.san = l[26,12].strip
49
+ msg.batch = l[38,6].strip
50
+ msg.code = l[44,1].strip
51
+ elsif !l[0,11].eql?("**TRAILER**")
52
+ product = Bisac::Product.from_string(l)
53
+ msg << product unless product.nil?
54
+ end
55
+ end
56
+ end
57
+
58
+ if msg.company.nil? || msg.san.nil? || msg.batch.nil? || msg.code.nil?
59
+ return nil
60
+ else
61
+ return msg
62
+ end
63
+ end
64
+
65
+ # adds a new title to the bisic file. Expects a Bisac::Product object.
66
+ def << (item)
67
+ unless item.class == Bisac::Product
68
+ raise ArgumentError, 'item must be a Bisac::Product object'
69
+ end
70
+ @products << item
71
+ end
72
+
73
+ # converts this bisac file into a string
74
+ def to_s
75
+ # File Header
76
+ content = "**HEADER**"
77
+ content << Time.now.strftime("%y%m%d")
78
+ content << @company[0,10].ljust(10)
79
+ content << @san[0,12].ljust(12)
80
+ content << @batch[0,6].ljust(6)
81
+ content << @code[0,1].ljust(1)
82
+ content << "**PUBSTAT*"
83
+ content << "040"
84
+ content << "".ljust(201)
85
+ content << "\n"
86
+
87
+ # File Content
88
+ counter = 0
89
+ @products.each do |item|
90
+ content << item.to_s + "\n"
91
+ counter = counter + 1
92
+ end
93
+
94
+ # File Footer
95
+ content << "**TRAILER*"
96
+ content << Time.now.strftime("%y%m%d")
97
+ content << @batch[0,6].ljust(6)
98
+ content << counter.to_s[0,6].ljust(6)
99
+ content << "**PUBSTAT*"
100
+ content << "".ljust(221)
101
+
102
+ return content
103
+ end
104
+
105
+ # writes the content of this bisac file out to the specified file
106
+ def write(filename)
107
+ File.open(filename, "w") { |f| f.puts to_s }
108
+ end
109
+ end
110
+ end
data/lib/bisac/po.rb ADDED
@@ -0,0 +1,252 @@
1
+ module Bisac
2
+
3
+ # Represents a single BISAC purchase order.
4
+ #
5
+ # = Generating
6
+ #
7
+ # po = Bisac::PO.new
8
+ # po.source_san = "1111111"
9
+ # po.source_name = "James"
10
+ # ...
11
+ # item = Bisac::POLineItem.new
12
+ # item.isbn = "0385519869"
13
+ # item.qty = 2
14
+ # po.items << item
15
+ # puts po.to_s
16
+ #
17
+ # = Reading
18
+ #
19
+ # Each PO file can contain multiple PO's, so use pasrse_file() to iterate
20
+ # over them all.
21
+ #
22
+ # Bisac::PO.parse_file("filename.bsc") do |msg|
23
+ # puts msg.source_san
24
+ # puts msg.source_name
25
+ # puts msg.items.size
26
+ # ...
27
+ # end
28
+ class PO
29
+
30
+ attr_accessor :source_san, :source_suffix, :source_name
31
+ attr_accessor :date, :filename, :format_version
32
+ attr_accessor :destination_san, :destination_suffix
33
+ attr_accessor :po_number, :cancellation_date, :backorder
34
+ attr_accessor :do_not_exceed_action, :do_not_exceed_amount
35
+ attr_accessor :invoice_copies, :special_instructions
36
+ attr_accessor :do_not_ship_before
37
+ attr_accessor :items
38
+
39
+ # creates a new RBook::Bisac::PO object
40
+ def initialize
41
+ @items = []
42
+
43
+ # default values
44
+ @cancellation_date = "000000"
45
+ @do_not_ship_before = "000000"
46
+ @backorder = true
47
+ end
48
+
49
+ # reads a bisac text file into memory. input should be a string
50
+ # that specifies the file path
51
+ def self.load_from_file(input)
52
+ $stderr.puts "WARNING: RBook::Bisac::PO.load_from_file is deprecated. It only returns the first PO in the file. use parse_file instead."
53
+ self.parse_file(input) { |msg| return msg }
54
+ return nil
55
+ end
56
+
57
+ # return all POs from a BISAC file
58
+ def self.parse_file(input, &block)
59
+ raise ArgumentError, 'no file provided' if input.nil?
60
+ raise ArgumentError, 'Invalid file' unless File.file?(input)
61
+ data = []
62
+ File.open(input, "r") do |f|
63
+ f.each_line do |l|
64
+ data << l
65
+
66
+ # yield each message found in the file. A line starting with
67
+ # 90 is the footer to a PO
68
+ if data.last[0,2] == "90"
69
+ yield self.build_message(data)
70
+ data = []
71
+ end
72
+ end
73
+ end
74
+
75
+ # if we've got to the end of the file, and haven't hit a footer line yet, the file
76
+ # is probably malformed. Call build_message anyway, and let it detect any errors
77
+ yield self.build_message(data) if data.size > 0
78
+ end
79
+
80
+ # return all POs from a BISAC string
81
+ def self.parse_string(input, &block)
82
+ raise ArgumentError, 'no data provided' if input.nil?
83
+ data = []
84
+ input.split("\n").each do |l|
85
+ data << l
86
+
87
+ # yield each message found in the string. A line starting with
88
+ # 90 is the footer to a PO
89
+ if data.last[0,2] == "90"
90
+ yield self.build_message(data)
91
+ data = []
92
+ end
93
+ end
94
+
95
+ # if we've got to the end of the file, and haven't hit a footer line yet, the file
96
+ # is probably malformed. Call build_message anyway, and let it detect any errors
97
+ yield self.build_message(data) if data.size > 0
98
+ end
99
+
100
+ # creates a RBook::Bisac::PO object from a string. Input should
101
+ # be a complete bisac file as a string
102
+ def self.load_from_string(input)
103
+ $stderr.puts "WARNING: Bisac::PO.load_from_string is deprecated. It only returns the first PO in the string. use parse_string instead."
104
+ data = input.split("\n")
105
+ return self.build_message(data)
106
+ end
107
+
108
+ def total_qty
109
+ @items.collect { |i| i.qty }.inject { |sum, x| sum ? sum+x : x}
110
+ end
111
+
112
+ def to_s
113
+ lines = []
114
+
115
+ # file header
116
+ line = " " * 80
117
+ line[0,2] = "00" # line type
118
+ line[2,5] = "00001" # line counter
119
+ line[7,7] = pad_trunc(@source_san, 7)
120
+ line[14,5] = pad_trunc(@source_suffix, 5)
121
+ line[19,13] = pad_trunc(@source_name, 13)
122
+ line[32,6] = pad_trunc(@date, 6)
123
+ line[38,22] = pad_trunc(@filename, 22)
124
+ line[60,3] = pad_trunc(@format_version, 3)
125
+ line[63,7] = pad_trunc(@destination_san, 7)
126
+ line[70,5] = pad_trunc(@destination_suffix, 5)
127
+ lines << line
128
+
129
+ # po header
130
+ lines << ""
131
+ lines.last << "10"
132
+ lines.last << "00002" # line counter
133
+ lines.last << " "
134
+ lines.last << @po_number.to_s.ljust(11, " ")
135
+ lines.last << " " # TODO
136
+ lines.last << pad_trunc(@source_san, 7)
137
+ lines.last << pad_trunc("",5) # TODO
138
+ lines.last << pad_trunc(@destination_san, 7)
139
+ lines.last << pad_trunc("",5) # TODO
140
+ lines.last << pad_trunc(@date, 6)
141
+ lines.last << pad_trunc(@cancellation_date,6)
142
+ lines.last << yes_no(@backorder)
143
+ lines.last << pad_trunc(@do_not_exceed_action,1)
144
+ lines.last << pad_trunc(@do_not_exceed_amount,7)
145
+ lines.last << pad_trunc(@invoice_copies,2)
146
+ lines.last << yes_no(@special_instructions)
147
+ lines.last << pad_trunc("",5) # TODO
148
+ lines.last << pad_trunc(@do_not_ship_before,6)
149
+
150
+ sequence = 3
151
+ @items.each_with_index do |item, idx|
152
+ item.line_item_number = idx + 1
153
+ item.sequence_number = sequence
154
+ lines += item.to_s.split("\n")
155
+ sequence += 3
156
+ end
157
+
158
+ # PO control
159
+ line = " " * 80
160
+ line[0,2] = "50"
161
+ line[2,5] = (lines.size + 1).to_s.rjust(5,"0") # line counter
162
+ line[8,12] = @po_number.to_s.ljust(13, " ")
163
+ line[20,5] = "00001" # number of POs in file
164
+ line[25,10] = @items.size.to_s.rjust(10,"0")
165
+ line[35,10] = total_qty.to_s.rjust(10,"0")
166
+ lines << line
167
+
168
+ # file trailer
169
+ line = " " * 80
170
+ line[0,2] = "90"
171
+ line[2,5] = (lines.size+1).to_s.rjust(5,"0") # line counter
172
+ line[7,20] = @items.size.to_s.rjust(13,"0")
173
+ line[20,5] = "00001" # total '10' (PO) records
174
+ line[25,10] = total_qty.to_s.rjust(10,"0")
175
+ line[35,5] = "00001" # number of '00'-'09' records
176
+ line[40,5] = "00001" # number of '10'-'19' records
177
+ line[55,5] = (@items.size * 3).to_s.rjust(5,"0") # number of '40'-'49' records
178
+ line[60,5] = "00000" # number of '50'-'59' records
179
+ line[45,5] = "00000" # number of '60'-'69' records
180
+ lines << line
181
+
182
+ lines.join("\n")
183
+ end
184
+
185
+ private
186
+
187
+ def yes_no(bool)
188
+ bool ? "Y" : "N"
189
+ end
190
+
191
+ def pad_trunc(str, len, pad = " ")
192
+ str = str.to_s
193
+ if str.size > len
194
+ str[0,len]
195
+ else
196
+ str.ljust(len, pad)
197
+ end
198
+ end
199
+
200
+ def self.build_message(data)
201
+ raise Bisac::InvalidFileError, 'File appears to be too short' unless data.size >= 3
202
+ raise Bisac::InvalidFileError, 'Missing header information' unless data[0][0,2].eql?("00")
203
+ raise Bisac::InvalidFileError, 'Missing footer information' unless data[-1][0,2].eql?("90")
204
+
205
+ msg = PO.new
206
+
207
+ data.each do |line|
208
+
209
+ case line[0,2]
210
+ when "00" # first header
211
+ msg.source_san = line[7,7].strip
212
+ msg.source_suffix = line[14,5].strip
213
+ msg.source_name = line[19,13].strip
214
+ msg.date = line[32,6].strip
215
+ msg.filename = line[38,22].strip
216
+ msg.format_version = line[60,3].strip
217
+ msg.destination_san = line[63,7].strip
218
+ msg.destination_suffix = line[70,5].strip
219
+ when "10" # second header
220
+ msg.po_number = line[7, 12].strip
221
+ msg.cancellation_date = line[50,6].strip
222
+ msg.backorder = line[56,1].eql?("Y") ? true : false
223
+ msg.do_not_exceed_action = line[57,1].strip
224
+ msg.do_not_exceed_amount = line[58,7].strip
225
+ msg.invoice_copies = line[65,2].strip
226
+ msg.special_instructions = line[67,1].eql?("Y") ? true : false
227
+ msg.do_not_ship_before = line[73,6].strip
228
+ when "40" # line item
229
+ # load each lineitem into the message
230
+ item = Bisac::POLineItem.load_from_string(line)
231
+ msg.items << item
232
+ when "41"
233
+ if line.length > 21
234
+ title = line[21, line.length - 21]
235
+ msg.items.last.title = title.strip unless title.nil?
236
+ end
237
+ when "42"
238
+ if line.length > 21
239
+ author = line[21, line.length - 21]
240
+ msg.items.last.author = author.strip unless author.nil?
241
+ end
242
+ when "90" # footer
243
+ # check the built objects match the file
244
+ end
245
+
246
+ end
247
+
248
+ # return the results
249
+ return msg
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,115 @@
1
+ require 'bigdecimal'
2
+
3
+ module Bisac
4
+
5
+ # represents a single line on the purchase order. Has attributes like
6
+ # price, qty and description
7
+ class POLineItem
8
+
9
+ attr_accessor :sequence_number, :po_number, :line_item_number
10
+ attr_accessor :qty, :catalogue_code, :price
11
+ attr_accessor :author, :title
12
+ attr_reader :isbn
13
+
14
+ # returns a new Bisac::POLineItem object using the data passed in as a string
15
+ # refer to the bisac spec for the expected format of the string
16
+ def self.load_from_string(data)
17
+ raise ArgumentError, 'data must be a string' unless data.kind_of? String
18
+ data.strip!
19
+
20
+ item = self.new
21
+
22
+ item.sequence_number = data[2,5].to_i
23
+ item.po_number = data[7,13].strip
24
+ item.line_item_number = data[21,10].strip
25
+
26
+ # prefer the 13 digit ISBN if it exists (it's a non-standard, Pacstream extension)
27
+ # fallback to the ISBN10
28
+ item.isbn = data[80,13]
29
+ item.isbn ||= data[31,10]
30
+ item.isbn.strip!
31
+ item.qty = data[41,5].to_i
32
+ item.catalogue_code = data[46,1].strip
33
+ item.price = BigDecimal.new(data[47,6])
34
+
35
+ return item
36
+ end
37
+
38
+ def isbn=(val)
39
+ if RBook::ISBN.valid_isbn13?(val)
40
+ @isbn = val
41
+ elsif RBook::ISBN.valid_isbn10?(val)
42
+ @isbn = RBook::ISBN.convert_to_isbn13(val)
43
+ else
44
+ @isbn = val
45
+ end
46
+ end
47
+
48
+ # is the isbn for this product valid?
49
+ def isbn?
50
+ RBook::ISBN.valid_isbn13?(@isbn || "")
51
+ end
52
+
53
+ def isbn10
54
+ if isbn?
55
+ RBook::ISBN.convert_to_isbn10(@isbn)
56
+ else
57
+ @isbn
58
+ end
59
+ end
60
+
61
+ def to_s
62
+ lines = ["","",""]
63
+ lines[0] << "40"
64
+ lines[0] << @sequence_number.to_s.rjust(5,"0")
65
+ lines[0] << " "
66
+ lines[0] << @po_number.to_s.ljust(11," ")
67
+ lines[0] << " " # TODO
68
+ lines[0] << "Y" # TODO
69
+ lines[0] << @line_item_number.to_s.rjust(10,"0")
70
+ lines[0] << pad_trunc(isbn10, 10)
71
+ lines[0] << @qty.to_s.rjust(5, "0")
72
+ lines[0] << "00000000000000000000000000000" # TODO
73
+ lines[0] << " "
74
+
75
+ # if we're ordering a valid ISBN, append a non-standard
76
+ # ISBN13 to the line
77
+ if isbn?
78
+ lines[0] << @isbn
79
+ end
80
+
81
+ lines << ""
82
+ lines[1] << "41"
83
+ lines[1] << (@sequence_number + 1).to_s.rjust(5,"0")
84
+ lines[1] << " "
85
+ lines[1] << @po_number.to_s.ljust(11," ")
86
+ lines[1] << " "
87
+ lines[1] << pad_trunc(@title, 30)
88
+
89
+ lines << ""
90
+ lines[2] << "42"
91
+ lines[2] << (@sequence_number + 2).to_s.rjust(5,"0")
92
+ lines[2] << " "
93
+ lines[2] << @po_number.to_s.ljust(11," ")
94
+ lines[2] << " " # TODO
95
+ lines[2] << pad_trunc(@author, 30)
96
+
97
+ lines.join("\n")
98
+ end
99
+
100
+ private
101
+
102
+ def yes_no(bool)
103
+ bool ? "Y" : "N"
104
+ end
105
+
106
+ def pad_trunc(str, len, pad = " ")
107
+ str = str.to_s
108
+ if str.size > len
109
+ str[0,len]
110
+ else
111
+ str.ljust(len, pad)
112
+ end
113
+ end
114
+ end
115
+ end