Add support for classification of non-fiction books.
[quanlib.git] / book.rb
1
2 require 'nokogiri'
3 require 'zip'
4
5 require 'author'
6 require 'cover'
7 require 'store'
8
9 class Book
10   @@DC_NS_URL = 'http://purl.org/dc/elements/1.1/'
11
12   def initialize(store)
13     @author = nil
14     @classification_id = nil
15     @cover = nil
16     @description = nil
17     @path = nil
18     @series_id = nil
19     @store = store
20     @title = nil
21     @volume = nil
22   end
23
24   def load_from_file!(fileName)
25     @path = fileName
26     parse_file_name!(fileName)
27   end
28
29   def self.can_handle?(fileName)
30     if nil == fileName
31       return false
32     end
33
34     #puts "Filename:  " + fileName.to_s
35     lowerName = fileName.downcase()
36
37     if lowerName.end_with?(".epub")
38       return true
39     end
40
41     if lowerName.end_with?(".pdf")
42       return true
43     end
44
45     return false
46   end
47
48   def author
49     return @author
50   end
51
52   def author=(value)
53     @author = value
54   end
55
56   def classification_id
57     @classification_id
58   end
59
60   def classification_id=(value)
61     @classification_id = value
62   end
63
64   def cover
65     return @cover
66   end
67
68   def cover=(value)
69     @cover = value
70   end
71
72   def description
73     @description
74   end
75
76   def description=(value)
77     @description = value
78   end
79
80   def heading
81     result = []
82
83     if nil != @title
84       result.push('<b>' + @title + '</b>')
85     else
86       result.push('<i>(Unknown title)</i>')
87     end
88     if nil != @author
89       result.push('<i>by ' + @author.reading_order + '</i>')
90     end
91     
92     seriesInfo = []
93     series = @store.load_series(@series_id)
94     if nil != series and nil != series.descr
95       seriesInfo.push(series.descr.to_s)
96     end
97     if nil != @volume
98       seriesInfo.push(@volume.to_s)
99     end
100     if seriesInfo.length > 0
101       result.push(seriesInfo.join(' '))
102     end
103
104     return result.join('<br/>')
105   end
106
107   def inspect
108     data = []
109     if nil != @author
110       data.push('author="' + @author.inspect + '"')
111     end
112     if nil != @series_id
113       data.push('series_id="' + @series_id.to_s() + '"')
114     end
115     if nil != @volume
116       data.push('volume="' + @volume + '"')
117     end
118     if nil != @title
119       data.push('title="' + @title + '"')
120     end
121     if nil != @cover
122       data.push(@cover.inspect())
123     end
124     if nil != @path
125       data.push('path="' + @path + '"')
126     end
127     return '(Book:' + data.join(',') + ')'
128   end
129
130   def path
131     @path
132   end
133
134   def path=(value)
135     @path = value
136   end
137
138   def series_id
139     @series_id
140   end
141   
142   def series_id=(value)
143     @series_id = value
144   end
145
146   def to_s
147     return inspect()
148   end
149
150   def title
151     @title
152   end
153
154   def title=(value)
155     @title = value
156   end
157
158   def title_grouping
159     if nil == @path
160       return nil
161     end
162
163     return File.basename(@path, '.*')
164   end
165
166   def volume
167     @volume
168   end
169
170   def volume=(value)
171     @volume = value
172   end
173
174   protected
175   def isUpper?(c)
176     return /[[:upper:]]/.match(c)
177   end
178
179   protected
180   def massage_author(input)
181     if nil == input
182       return nil
183     end
184
185     reading_order = ""
186     input.each_char do |c|
187       if isUpper?(c) and (reading_order.length > 0)
188         reading_order += " "
189      end
190       reading_order += c
191     end
192
193     return reading_order
194   end
195
196   # Returns (series, volumeNo, titleText)
197   protected
198   def processTitle(input)
199     if nil == input
200       return nil
201     end
202
203     arr = input.split('_')
204
205     series = nil
206     vol = nil
207
208     first = arr[0]
209     matchData = (arr[0]).match(/^([A-Z]+)([0-9]+)$/)
210     if nil != matchData
211       capt = matchData.captures
212       series = capt[0]
213       vol = capt[1]
214       arr.shift
215     end
216
217     pos = arr[-1].rindex('.')
218     if nil != pos
219       arr[-1] = arr[-1].slice(0, pos)
220     end
221
222     title = arr.join(' ')
223
224     return series, vol, title
225   end
226
227   protected
228   def parse_file_name!(file_name)
229     parts = file_name.split('/')
230     (series_code, @volume, @title) = processTitle(parts[-1])
231     if parts.length > 1
232       grouping = parts[-2]
233       reading_order = massage_author(grouping)
234       sort_order = nil
235       @author = Author.new(grouping, reading_order, sort_order)
236       @series_id = @store.get_series(grouping, series_code)
237     end
238
239     lc_file_name = file_name.downcase
240     if lc_file_name.end_with?(".epub")
241       scanEpub!(file_name)
242     elsif lc_file_name.end_with?(".pdf")
243       scan_pdf!(file_name)
244     end
245
246     @classification_id = @store.find_classification(@author.grouping, File.basename(file_name, '.*'))
247   end
248
249   protected 
250   def scanEpub!(fileName)
251     #puts 'Scanning "' + fileName.to_s + '"...'
252     begin
253       Zip::File.open(fileName) do |zipfile|
254         entry = zipfile.find_entry('META-INF/container.xml')
255         if nil == entry
256           puts 'No META-INF/container.xml, skipping book ' + fileName
257           return
258         end
259         contXml = zipfile.read('META-INF/container.xml')
260         contDoc = Nokogiri::XML(contXml)
261         opfPath = contDoc.css("container rootfiles rootfile")[0]['full-path']
262
263         scanOpf!(zipfile, opfPath)
264       end
265     rescue Zip::Error => exc
266       puts 'ERROR processing file "' + fileName + '":'
267       puts exc.message
268       puts exc.backtrace
269     end
270   end
271
272   protected
273   def scan_pdf!(file_name)
274     #puts 'Scanning "' + file_name.to_s + '"...'
275
276     pdf_path = File.expand_path(file_name).to_s
277     if ! pdf_path.end_with?('.pdf')
278       puts 'Unexpected internal error:  path "' + file_name.to_s + '" does not end with ".pdf".'
279       return
280     end
281
282     jpeg_path = pdf_path[0..-5] + '.jpeg'
283     if File.file?(jpeg_path)
284       File.open(jpeg_path, 'r') do |is|
285         @cover = Cover.new(is, jpeg_path, 'image/jpeg')
286       end
287     end
288   end
289
290
291   protected
292   def scanOpf!(zipfile, opfPath)
293     coverId = nil
294
295     opfXml = zipfile.read(opfPath)
296     opfDoc = Nokogiri::XML(opfXml)
297
298     #-------
299     # Author
300
301     grouping = @author.grouping
302     reading_order = @author.reading_order
303     sort_order = @author.sort_order
304
305     creators = opfDoc.css('dc|creator', 'dc' => @@DC_NS_URL)
306     if (creators.length > 0)
307       creator = creators[0]
308       if nil != creator
309         role = creator['opf:role']
310         if 'aut' == role
311           reading_order = creator.content
312
313           file_as = creator['opf:file-as']
314           if nil != file_as
315             sort_order = file_as
316           end
317         end
318
319         @author = Author.new(grouping, reading_order, sort_order)
320       end
321     end
322
323     #---------------------------------------
324     # Title
325
326     titles = opfDoc.css('dc|title', 'dc' => @@DC_NS_URL)
327     if titles.length > 0
328       title = titles[0]
329       if nil != title
330         @title = title.content
331       end
332     end
333
334     #---------------------------------------
335     # Description
336     
337     descrNodes = opfDoc.css('dc|description', 'dc' => @@DC_NS_URL)
338     if (descrNodes.length > 0)
339       descrNode = descrNodes[0]
340       if nil != descrNode
341         @description = descrNode.content
342       end
343     end
344
345     #---------------------------------------
346     # Other metadata:  series, volume, cover
347
348     metas = opfDoc.css('package metadata meta')
349     for m in metas
350       name = m['name']
351       content = m['content']
352
353       if 'calibre:series' == name
354         # TODO:  Dynamically create a new series?
355         # @series_id = content
356       elsif 'calibre:series-index' == name
357         @volume = content
358       elsif 'cover' == name
359         coverId = content
360         #puts 'File ' + @path + ' coverId ' + coverId
361       end
362     end
363
364     #---------------
365     # Load the cover
366
367     @cover = load_cover(zipfile, opfPath, opfDoc, coverId)
368   end
369
370   protected
371   def load_cover(zipfile, opfPath, opfDoc, coverId)
372     coverFile = nil
373     if nil == coverId
374       coverId = "cover-image"
375     end
376
377     items = opfDoc.css('package manifest item')
378     for i in items
379       href = i['href']
380       id = i['id']
381       mimeType = i['media-type']
382
383       if coverId == id
384         entry = zipfile.find_entry(href)
385
386         if nil == entry
387           # Although the epub standard requires the path to be relative 
388           # to the base of the epub (zip), some books encountered in the
389           # wild have been found to use a bath relative to the location 
390           # of the opf file.
391           parts = opfPath.split('/')
392           opfBasePath = opfPath.split('/')[0..-2].join('/')
393           coverPath = opfBasePath + '/' + href
394           entry = zipfile.find_entry(coverPath)
395         end
396
397         if nil == entry
398           puts 'WARNING!  Cover image "' + href + '" not found in file "' + @path + '".'
399           return nil
400         else
401           entry.get_input_stream() do |is|
402             return Cover.new(is, href, mimeType)
403           end
404         end
405       end
406     end
407     return nil
408   end
409 end
410