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