C0 code coverage information
Generated on Tue Oct 16 11:40:49 -0400 2007 with rcov 0.8.0
Code reported as executed by Ruby looks like this...
and this: this line is also marked as covered.
Lines considered as run by rcov, but not reported by Ruby, look like this,
and this: these lines were inferred by rcov (using simple heuristics).
Finally, here's a line marked as not executed.
1 # Copyright (C) 2004-2006 Laurent Sansonetti
2 #
3 # Alexandria is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License as
5 # published by the Free Software Foundation; either version 2 of the
6 # License, or (at your option) any later version.
7 #
8 # Alexandria is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
11 # General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public
14 # License along with Alexandria; see the file COPYING. If not,
15 # write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
16 # Boston, MA 02111-1307, USA.
17
18 require 'yaml'
19 require 'fileutils'
20 require 'rexml/document'
21 require 'tempfile'
22 require 'etc'
23 require 'open-uri'
24 require 'observer'
25 require 'singleton'
26
27 class Array
28 def sum
29 self.inject(0) { |a, b| a + b }
30 end
31 end
32
33 module Alexandria
34 class Library < Array
35 include Logging
36
37 attr_reader :name
38 attr_accessor :ruined_books
39 DIR = File.join(ENV['HOME'], '.alexandria')
40 EXT = { :book => '.yaml', :cover => '.cover' }
41
42 include GetText
43 extend GetText
44 bindtextdomain(Alexandria::TEXTDOMAIN, nil, nil, "UTF-8")
45
46 BOOK_ADDED, BOOK_UPDATED, BOOK_REMOVED = (0..3).to_a
47 include Observable
48
49 def path
50 File.join(DIR, @name)
51 end
52
53 def self.generate_new_name(existing_libraries,
54 from_base=_("Untitled"))
55 i = 1
56 name = nil
57 all_libraries = existing_libraries + @@deleted_libraries
58 while true do
59 name = i == 1 ? from_base : from_base + " #{i}"
60 break unless all_libraries.find { |x| x.name == name }
61 i += 1
62 end
63 return name
64 end
65
66 FIX_BIGNUM_REGEX =
67 /loaned_since:\s*(\!ruby\/object\:Bignum\s*)?(\d+)\n/
68
69 def self.load(name)
70 test = [0,nil]
71 ruined_books = []
72 library = Library.new(name)
73 FileUtils.mkdir_p(library.path) unless File.exists?(library.path)
74 Dir.chdir(library.path) do
75 Dir["*" + EXT[:book]].each do |filename|
76
77 test[1] = filename if test[0] == 0
78
79 #puts "back from the future of #{test[1]}:" if test[0] == 1
80 #puts "Regularizing book :#{test[1]}" if $DEBUG
81 if not File.size? test[1]
82 log.warn { "Book file #{test[1]} was empty"}
83 next
84 end
85 book = self.regularize_book_from_yaml(test[1])
86 #puts "File state for #{test[1].inspect}: " + book.to_yaml if test[0] == 1
87
88 old_isbn = book.isbn
89 old_pub_year = book.publishing_year
90 begin
91 #puts "Entering resave-test block for #{test[1]}" if $DEBUG
92 begin
93 book.isbn = self.canonicalise_ean(book.isbn).to_s unless book.isbn == nil
94 raise "Not a book: #{text.inspect}" unless book.is_a?(Book)
95 rescue InvalidISBNError => e
96 #puts e.message if $DEBUG
97 book.isbn = old_isbn
98 end
99
100 book.publishing_year = book.publishing_year.to_i unless book.publishing_year == nil
101
102 # Or if isbn has changed
103 raise "#{test[1]} isbn is not okay" unless book.isbn == old_isbn
104
105 # Re-save book if VERSION changes
106 raise "#{test[1]} version is not okay" unless book.version == VERSION
107
108 # Or if publishing year has changed
109 raise "#{test[1]} pub year is not okay" unless book.publishing_year == old_pub_year
110
111 # ruined_books << [book, book.isbn, library]
112 library << book
113 rescue => e
114 #puts "I'm reformatting #{test[1]} because #{e.message}"
115 book.version = VERSION
116 savedfilename = library.simple_save(book)
117 test[0] = test[0] + 1
118 test[1] = savedfilename
119 # retry ## no, this would retry the outer 'begin' block, not the Dir.each block
120
121
122 # retries the Dir.each block...
123 # but gives up after three tries
124 redo unless test[0] > 2
125
126 else
127 test = [0,nil]
128 end
129 end
130
131 # Since 0.4.0 the cover files '_small.jpg' and
132 # '_medium.jpg' have been deprecated for a single medium
133 # cover file named '.cover'.
134
135 Dir["*" + '_medium.jpg'].each do |medium_cover|
136 begin
137 FileUtils.mv(medium_cover,
138 medium_cover.sub(/_medium\.jpg$/,
139 EXT[:cover]))
140 rescue
141 end
142 end
143
144
145
146 Dir["*" + EXT[:cover]].each do |cover|
147 md = /(.+)\.cover/.match(cover)
148 begin
149 ean = self.canonicalise_ean(md[1])
150 rescue
151 ean = md[1]
152 end
153 begin
154 FileUtils.mv(cover, ean + EXT[:cover]) unless cover == ean + EXT[:cover]
155 rescue
156 end
157 end
158
159 FileUtils.rm_f(Dir['*_small.jpg'])
160 end
161 #puts ruined_books.inspect
162 library.ruined_books = ruined_books
163
164 library
165 end
166
167 def self.regularize_book_from_yaml(name)
168 text = IO.read(name)
169
170 #Code to remove the mystery string in books imported from Amazon
171 # (In the past, still?) To allow ruby-amazon to be removed.
172
173 # The string is removed on load, but can't make it stick, maybe has to do with cache
174
175 if /!str:Amazon::Search::Response/.match(text)
176 #puts text if $DEBUG
177 text.gsub!("!str:Amazon::Search::Response", "")
178 #puts "got one!" if $DEBUG
179 #puts text if $DEBUG
180 end
181
182 # Backward compatibility with versions <= 0.6.0, where the
183 # loaned_since field was a numeric.
184 if md = FIX_BIGNUM_REGEX.match(text)
185 new_yaml = Time.at(md[2].to_i).to_yaml
186 # Remove the "---" prefix.
187 new_yaml.sub!(/^\s*\-+\s*/, '')
188 text.sub!(md[0], "loaned_since: #{new_yaml}\n")
189 end
190 book = YAML.load(text)
191 unless book.isbn.class == String
192 # HACK
193 md = /isbn: (.+)/.match(text)
194 if md
195 string_isbn = md[1].strip
196 book.isbn = string_isbn
197 #puts "Reset to string value #{string_isbn}"
198 end
199 end
200 if book.isbn.class == String and book.isbn.length == 0
201 book.isbn = nil # save trouble later
202 end
203 book
204 end
205
206 def self.loadall
207 a = []
208 begin
209 Dir.entries(DIR).each do |file|
210 # Skip hidden files.
211 next if /^\./.match(file)
212 # Skip non-directory files.
213 next unless File.stat(File.join(DIR, file)).directory?
214
215 a << self.load(file)
216 end
217
218 rescue Errno::ENOENT
219 FileUtils.mkdir_p(DIR)
220 end
221 # Create the default library if there is no library yet.
222
223 if a.empty?
224 a << self.load(_("My Library"))
225 end
226
227 return a
228 end
229
230 def self.move(source_library, dest_library, *books)
231 dest = dest_library.path
232 books.each do |book|
233 FileUtils.mv(source_library.yaml(book), dest)
234 if File.exists?(source_library.cover(book))
235 FileUtils.mv(source_library.cover(book), dest)
236 end
237
238 source_library.changed
239 source_library.old_delete(book)
240 source_library.notify_observers(source_library,
241 BOOK_REMOVED,
242 book)
243
244 dest_library.changed
245 dest_library.delete_if { |book2| book2.ident == book.ident }
246 dest_library << book
247 dest_library.notify_observers(dest_library, BOOK_ADDED, book)
248 end
249 end
250
251 class NoISBNError < StandardError
252 def initialize(msg)
253 super(msg)
254 end
255 end
256
257 class InvalidISBNError < StandardError
258 attr_reader :isbn
259 def initialize(isbn=nil)
260 super()
261 @isbn = isbn
262 end
263 end
264
265 def self.extract_numbers(isbn)
266 raise NoISBNError.new("Nil ISBN") if isbn == nil
267
268 isbn.delete('- ').upcase.split('').map do |x|
269 raise InvalidISBNError.new(isbn) unless x =~ /[\dX]/
270 x == 'X' ? 10 : x.to_i
271 end
272 end
273
274 def self.isbn_checksum(numbers)
275 sum = (0 ... numbers.length).inject(0) do |accumulator, i|
276 accumulator + numbers[i] * (i + 1)
277 end % 11
278
279 sum == 10 ? 'X' : sum
280 end
281
282 def self.valid_isbn?(isbn)
283 begin
284 numbers = self.extract_numbers(isbn)
285 numbers.length == 10 and self.isbn_checksum(numbers) == 0
286 rescue InvalidISBNError
287 false
288 end
289 end
290
291 def self.ean_checksum(numbers)
292 (10 - ([1, 3, 5, 7, 9, 11].map { |x| numbers[x] }.sum * 3 +
293 [0, 2, 4, 6, 8, 10].map { |x| numbers[x] }.sum)) % 10
294 end
295
296 def self.valid_ean?(ean)
297 begin
298 numbers = self.extract_numbers(ean)
299 (numbers.length == 13 and
300 self.ean_checksum(numbers[0 .. 11]) == numbers[12]) or
301 (numbers.length == 18 and
302 self.ean_checksum(numbers[0 .. 11]) == numbers[12])
303 rescue InvalidISBNError
304 false
305 end
306 end
307
308 def self.upc_checksum(numbers)
309 (10 - ([0, 2, 4, 6, 8, 10].map { |x| numbers[x] }.sum * 3 +
310 [1, 3, 5, 7, 9].map { |x| numbers[x] }.sum)) % 10
311 end
312
313 def self.valid_upc?(upc)
314 begin
315 numbers = self.extract_numbers(upc)
316 (numbers.length == 17 and
317 self.upc_checksum(numbers[0 .. 10]) == numbers[11])
318 rescue InvalidISBNError
319 false
320 end
321 end
322
323 AMERICAN_UPC_LOOKUP = {
324 "014794" => "08041", "018926" => "0445", "02778" => "0449",
325 "037145" => "0812", "042799" => "0785", "043144" => "0688",
326 "044903" => "0312", "045863" => "0517", "046594" => "0064",
327 "047132" => "0152", "051487" => "08167", "051488" => "0140",
328 "060771" => "0002", "065373" => "0373", "070992" => "0523",
329 "070993" => "0446", "070999" => "0345", "071001" => "0380",
330 "071009" => "0440", "071125" => "088677", "071136" => "0451",
331 "071149" => "0451", "071152" => "0515", "071162" => "0451",
332 "071268" => "08217", "071831" => "0425", "071842" => "08439",
333 "072742" => "0441", "076714" => "0671", "076783" => "0553",
334 "076814" => "0449", "078021" => "0872", "079808" => "0394",
335 "090129" => "0679", "099455" => "0061", "099769" => "0451"
336 }
337
338 def self.upc_convert(upc)
339 test_upc = upc.map { |x| x.to_s }.join()
340 self.extract_numbers(AMERICAN_UPC_LOOKUP[test_upc])
341 end
342
343 def self.canonicalise_ean(code)
344 code = code.to_s.delete('- ')
345 if self.valid_ean?(code)
346 return code
347 elsif self.valid_isbn?(code)
348 code = "978" + code[0..8]
349 return code + String( self.ean_checksum( self.extract_numbers( code ) ) )
350 elsif self.valid_upc?(code)
351 raise "fix function Alexandria::Library.canonicalise_ean"
352 else
353 raise InvalidISBNError.new(code)
354 end
355 end
356
357 def self.canonicalise_isbn(isbn)
358 numbers = self.extract_numbers(isbn)
359 if self.valid_ean?(isbn) and numbers[0 .. 2] != [9,7,8]
360 return isbn
361 end
362 canonical = if self.valid_ean?(isbn)
363 # Looks like an EAN number -- extract the intersting part and
364 # calculate a checksum. It would be nice if we could validate
365 # the EAN number somehow.
366 numbers[3 .. 11] + [self.isbn_checksum(numbers[3 .. 11])]
367 elsif self.valid_upc?(isbn)
368 # Seems to be a valid UPC number.
369 prefix = self.upc_convert(numbers[0 .. 5])
370 isbn_sans_chcksm = prefix + numbers[(8 + prefix.length) .. 17]
371 isbn_sans_chcksm + [self.isbn_checksum(isbn_sans_chcksm)]
372 elsif self.valid_isbn?(isbn)
373 # Seems to be a valid ISBN number.
374 numbers[0 .. -2] + [self.isbn_checksum(numbers[0 .. -2])]
375 else
376 raise InvalidISBNError.new(isbn)
377 end
378
379 canonical.map { |x| x.to_s }.join()
380 end
381
382 def simple_save(book)
383 # Let's initialize the saved identifier if not already
384 # (backward compatibility from 0.4.0)
385 book.saved_ident ||= book.ident
386 if book.ident != book.saved_ident
387 #puts "Backwards compatibility step: #{book.saved_ident.inspect}, #{book.ident.inspect}" if $DEBUG
388 FileUtils.rm(yaml(book.saved_ident))
389 end
390 if File.exists?(cover(book.saved_ident))
391 begin
392 #puts "Moving cover #{cover(book.saved_ident)} to #{cover(book.ident)}" if $DEBUG
393 FileUtils.mv(cover(book.saved_ident), cover(book.ident))
394 rescue
395 end
396 end
397 book.saved_ident = book.ident
398
399 filename = book.saved_ident.to_s + ".yaml"
400 #puts filename
401 File.open(filename, "w") { |io| io.puts book.to_yaml }
402 #puts "outputting book data..."
403 #puts File.open(filename, "r").read
404 filename
405 end
406
407 def save(book, final=false)
408 changed unless final
409
410 #puts "Saving book #{book.title}..." if $DEBUG
411
412 # Let's initialize the saved identifier if not already
413 # (backward compatibility from 0.4.0).
414 book.saved_ident ||= book.ident
415
416 #puts "#{book.title}'s saved_ident is #{book.saved_ident}" if $DEBUG
417
418 if book.ident != book.saved_ident
419 FileUtils.rm(yaml(book.saved_ident))
420 if File.exists?(cover(book.saved_ident))
421 FileUtils.mv(cover(book.saved_ident), cover(book.ident))
422 end
423
424 # Notify before updating the saved identifier, so the views
425 # can still use the old one to update their models.
426 notify_observers(self, BOOK_UPDATED, book) unless final
427 book.saved_ident = book.ident
428 end
429 already_there = (File.exists?(yaml(book)) and
430 !@deleted_books.include?(book))
431
432 #puts "Doing the saving deed: #{book.title} -- #{book.isbn}" if $DEBUG
433 File.open(yaml(book), "w") { |io| io.puts book.to_yaml }
434
435 # Do not notify twice.
436 if changed?
437 notify_observers(self,
438 already_there ? BOOK_UPDATED : BOOK_ADDED,
439 book)
440 end
441 end
442
443 def transport
444 config = Alexandria::Preferences.instance.http_proxy_config
445 config ? Net::HTTP.Proxy(*config) : Net::HTTP
446 end
447
448 def save_cover(book, cover_uri)
449 Dir.chdir(self.path) do
450 # Fetch the cover picture.
451 cover_file = cover(book)
452 File.open(cover_file, "w") do |io|
453 uri = URI.parse(cover_uri)
454 if uri.scheme.nil?
455 # Regular filename.
456 File.open(cover_uri) { |io2| io.puts io2.read }
457 else
458 # Try open-uri.
459 io.puts transport.get(uri)
460 end
461 end
462
463 # Remove the file if its blank.
464 if Alexandria::UI::Icons.blank?(cover_file)
465 File.delete(cover_file)
466 end
467 end
468 end
469
470 @@deleted_libraries = []
471
472 def self.deleted_libraries
473 @@deleted_libraries
474 end
475
476 def self.really_delete_deleted_libraries
477 @@deleted_libraries.each do |library|
478 #puts "Deleting library directory (#{library.path})" if $DEBUG
479 FileUtils.rm_rf(library.path)
480 end
481 end
482
483 def really_delete_deleted_books
484 @deleted_books.each do |book|
485 [yaml(book), cover(book)].each do |file|
486 #puts "Deleting book file #{file} " if $DEBUG
487 FileUtils.rm_f(file)
488 end
489 end
490 end
491
492 alias_method :old_delete, :delete
493 def delete(book=nil)
494 if book.nil?
495 # Delete the whole library.
496 raise if @@deleted_libraries.include?(self)
497 @@deleted_libraries << self
498 else
499 if @deleted_books.include?(book)
500 doubles = @deleted_books.reject { |b| not b.equal? book }
501 raise "Book #{book.isbn} was already deleted" unless doubles.empty?
502 end
503 @deleted_books << book
504 i = self.index(book)
505 # We check object IDs there because the user could have added
506 # a book with the same identifier as another book he/she
507 # previously deleted and that he/she is trying to redo.
508 if i and self[i].equal? book
509 changed
510 old_delete(book) # FIX this will old_delete all '==' books
511 notify_observers(self, BOOK_REMOVED, book)
512 end
513 end
514 end
515
516 def deleted?
517 @@deleted_libraries.include?(self)
518 end
519
520 def undelete(book=nil)
521 if book.nil?
522 # Undelete the whole library.
523 raise unless @@deleted_libraries.include?(self)
524 @@deleted_libraries.delete(self)
525 else
526 raise unless @deleted_books.include?(book)
527 @deleted_books.delete(book)
528 unless self.include?(book)
529 changed
530 self << book
531 notify_observers(self, BOOK_ADDED, book)
532 end
533 end
534 end
535
536 alias_method :old_select, :select
537 def select
538 filtered_library = Library.new(@name)
539 self.each do |book|
540 filtered_library << book if yield(book)
541 end
542 return filtered_library
543 end
544
545 def cover(something)
546 ident = case something
547 when Book
548 something.ident
549 when String
550 something
551 when Bignum
552 something
553 when Fixnum
554 something
555 else
556 raise "#{something} is a #{something.class}"
557 end
558 File.join(self.path, ident.to_s + EXT[:cover])
559 end
560
561 def yaml(something, basedir=self.path)
562 ident = case something
563 when Book
564 something.ident
565 when String
566 something
567 when Bignum
568 something
569 when Fixnum
570 something
571 else
572 raise "#{something} is #{something.class}"
573 end
574 File.join(basedir, ident.to_s + EXT[:book])
575 end
576
577 def name=(name)
578 File.rename(path, File.join(DIR, name))
579 @name = name
580 end
581
582 def n_rated
583 select { |x| !x.rating.nil? and x.rating > 0 }.length
584 end
585
586 def n_unrated
587 length - n_rated
588 end
589
590 def ==(object)
591 object.is_a?(self.class) && object.name == self.name
592 end
593
594 def copy_covers(somewhere)
595 FileUtils.rm_rf(somewhere) if File.exists?(somewhere)
596 FileUtils.mkdir(somewhere)
597 each do |book|
598 next unless File.exists?(cover(book))
599 FileUtils.cp(File.join(self.path, book.ident + EXT[:cover]),
600 File.join(somewhere, final_cover(book)))
601 end
602 end
603
604 def self.jpeg?(file)
605 'JFIF' == IO.read(file, 10)[6..9]
606 end
607
608 def final_cover(book)
609 book.ident + (Library.jpeg?(cover(book)) ? '.jpg' : '.gif')
610 end
611
612 #########
613 protected
614 #########
615
616 def initialize(name)
617 @name = name
618 @deleted_books = []
619 end
620 end
621
622 class Libraries
623 attr_reader :all_libraries, :ruined_books
624
625 include Observable
626 include Singleton
627
628 def reload
629 @all_libraries.clear
630 @all_libraries.concat(Library.loadall)
631 @all_libraries.concat(SmartLibrary.loadall)
632 ruined = []
633 last = []
634 all_regular_libraries.each {|library|
635 ruined += library.ruined_books
636 }
637 #puts ruined.inspect
638 @ruined_books = ruined
639 end
640
641 def all_regular_libraries
642 @all_libraries.select { |x| x.is_a?(Library) }
643 end
644
645 def all_smart_libraries
646 @all_libraries.select { |x| x.is_a?(SmartLibrary) }
647 end
648
649 LIBRARY_ADDED, LIBRARY_REMOVED = 1, 2
650
651 def add_library(library)
652 @all_libraries << library
653 notify(LIBRARY_ADDED, library)
654 end
655
656 def remove_library(library)
657 @all_libraries.delete(library)
658 notify(LIBRARY_REMOVED, library)
659 end
660
661 def really_delete_deleted_libraries
662 Library.really_delete_deleted_libraries
663 SmartLibrary.really_delete_deleted_libraries
664 end
665
666 def really_save_all_books
667 all_regular_libraries.each do |library|
668 library.each {|book| library.save(book, true)}
669 end
670 end
671
672 #######
673 private
674 #######
675
676 def initialize
677 @all_libraries = []
678 end
679
680 def notify(action, library)
681 changed
682 notify_observers(self, action, library)
683 end
684 end
685 end
Generated using the rcov code coverage analysis tool for Ruby version 0.8.0.