bisac 0.8

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