bisac 0.8
Sign up to get free protection for your applications and to get access to all the features.
- 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
|