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/COPYING +340 -0
- data/LICENSE +12 -0
- data/README +24 -0
- data/Rakefile +90 -0
- data/lib/bisac.rb +17 -0
- data/lib/bisac/message.rb +110 -0
- data/lib/bisac/po.rb +252 -0
- data/lib/bisac/po_line_item.rb +115 -0
- data/lib/bisac/poa.rb +212 -0
- data/lib/bisac/poa_line_item.rb +81 -0
- data/lib/bisac/product.rb +174 -0
- data/specs/data/bisac_multi_po.txt +41 -0
- data/specs/data/bisac_po.txt +112 -0
- data/specs/data/bisac_po_no_footer.txt +111 -0
- data/specs/data/bisac_po_no_header.txt +111 -0
- data/specs/data/single_product.xml +74 -0
- data/specs/data/valid_bisac.txt +213 -0
- data/specs/data/valid_poa.txt +83 -0
- data/specs/new_bisac_spec.rb +67 -0
- data/specs/new_po_line_item_spec.rb +52 -0
- data/specs/new_po_spec.rb +85 -0
- data/specs/poa_line_item_spec.rb +36 -0
- data/specs/poa_spec.rb +70 -0
- metadata +96 -0
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
|