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