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