class Alexandria::Library

Constants

AMERICAN_UPC_LOOKUP
BOOK_REMOVED
DEFAULT_DIR
EXT

Attributes

deleted_books[RW]
name[R]
ruined_books[RW]
updating[RW]

Public Class Methods

canonicalise_ean(code) click to toggle source
# File lib/alexandria/models/library.rb, line 142
def self.canonicalise_ean(code)
  code = code.to_s.delete("- ")
  if valid_ean?(code)
    code
  elsif valid_isbn?(code)
    code = "978" + code[0..8]
    code + String(ean_checksum(extract_numbers(code)))
  elsif valid_upc?(code)
    isbn10 = canonicalise_isbn
    code = "978" + isbn10[0..8]
    code + String(ean_checksum(extract_numbers(code)))
  end
end
canonicalise_isbn(isbn) click to toggle source
# File lib/alexandria/models/library.rb, line 156
def self.canonicalise_isbn(isbn)
  numbers = extract_numbers(isbn)
  return isbn if valid_ean?(isbn) && (numbers[0..2] != [9, 7, 8])

  canonical = if valid_ean?(isbn)
                # Looks like an EAN number -- extract the intersting part and
                # calculate a checksum. It would be nice if we could validate
                # the EAN number somehow.
                numbers[3..11] + [isbn_checksum(numbers[3..11])]
              elsif valid_upc?(isbn)
                # Seems to be a valid UPC number.
                prefix = upc_convert(numbers[0..5])
                isbn_sans_chcksm = prefix + numbers[(8 + prefix.length)..17]
                isbn_sans_chcksm + [isbn_checksum(isbn_sans_chcksm)]
              elsif valid_isbn?(isbn)
                # Seems to be a valid ISBN number.
                numbers[0..-2] + [isbn_checksum(numbers[0..-2])]
              end

  return unless canonical

  canonical.map(&:to_s).join
end
deleted_libraries() click to toggle source
# File lib/alexandria/models/library.rb, line 259
def self.deleted_libraries
  @@deleted_libraries
end
ean_checksum(numbers) click to toggle source
# File lib/alexandria/models/library.rb, line 98
def self.ean_checksum(numbers)
  -(numbers.values_at(1, 3, 5, 7, 9, 11).sum * 3 +
    numbers.values_at(0, 2, 4, 6, 8, 10).sum) % 10
end
extract_numbers(entry) click to toggle source
# File lib/alexandria/models/library.rb, line 74
def self.extract_numbers(entry)
  return [] if entry.nil? || entry.empty?

  normalized = entry.delete("- ").upcase
  return [] unless /\A[\dX]*\Z/.match?(normalized)

  normalized.split("").map do |char|
    char == "X" ? 10 : char.to_i
  end
end
generate_new_name(existing_libraries, from_base = _("Untitled")) click to toggle source
# File lib/alexandria/models/library.rb, line 39
def self.generate_new_name(existing_libraries,
                           from_base = _("Untitled"))
  i = 1
  name = nil
  all_libraries = existing_libraries + @@deleted_libraries
  loop do
    name = i == 1 ? from_base : from_base + " #{i}"
    break unless all_libraries.find { |x| x.name == name }

    i += 1
  end
  name
end
identify_csv_type(header) click to toggle source

LibraryThing has 15 fields (Apr 2010), Goodreads has 29 we shall guess that “PUBLICATION INFO” implies LibraryThing and “Year Published” implies Goodreads

# File lib/alexandria/import_library_csv.rb, line 182
def self.identify_csv_type(header)
  is_librarything = false
  is_goodreads = false
  header.each do |head|
    case head
    when "'PUBLICATION INFO'"
      is_librarything = true
      break
    when "Year Published"
      is_goodreads = true
      break
    end
  end

  return LibraryThingCSVImport.new(header) if is_librarything
  return GoodreadsCSVImport.new(header) if is_goodreads

  raise _("Not Recognized") unless is_librarything || is_goodreads
end
import_as_csv_file(name, filename, on_iterate_cb, _on_error_cb) click to toggle source
# File lib/alexandria/import_library.rb, line 139
def self.import_as_csv_file(name, filename, on_iterate_cb, _on_error_cb)
  require "alexandria/import_library_csv"
  books_and_covers = []
  line_count = IO.readlines(filename).reduce(0) { |count, _line| count + 1 }

  import_count = 0
  max_import = line_count - 1

  reader = CSV.open(filename, "r")
  # Goodreads & LibraryThing now use csv header lines
  header = reader.shift
  importer = identify_csv_type(header)
  failed_once = false
  begin
    reader.each do |row|
      book = importer.row_to_book(row)
      cover = nil
      if book.isbn
        # if we can search by ISBN, try to grab the cover
        begin
          dl_book, dl_cover = BookProviders.isbn_search(book.isbn)
          if dl_book.authors.size > book.authors.size
            # LibraryThing only supports a single author, so
            # attempt to include more author information if it's
            # available
            book.authors = dl_book.authors
          end
          book.edition = dl_book.edition unless book.edition
          cover = dl_cover
        rescue StandardError
          log.debug { "Failed to get cover for #{book.title} #{book.isbn}" }
        end
      end

      books_and_covers << [book, cover]
      import_count += 1
      on_iterate_cb&.call(import_count, max_import)
    end
  rescue CSV::IllegalFormatError
    unless failed_once
      failed_once = true

      # probably Goodreads' wonky ISBN fields ,,="043432432X",
      # this is a hack to fix up such files
      data = File.read(filename)
      data.gsub!(/,="/, ',"')
      csv_fixed = Tempfile.new("alexandria_import_csv_fixed_")
      csv_fixed.write(data)
      csv_fixed.close

      reader = CSV.open(csv_fixed.path, "r")
      header = reader.shift
      importer = identify_csv_type(header)

      retry
    end
  end

  # TODO: Pass in library store as an argument
  library = LibraryCollection.instance.library_store.load_library name

  books_and_covers.each do |book, cover_uri|
    log.debug { "Saving #{book.isbn} cover" }
    library.save_cover(book, cover_uri) unless cover_uri.nil?
    log.debug { "Saving #{book.isbn}" }
    library << book
    library.save(book)
  end
  [library, []]
end
import_as_isbn_list(name, filename, on_iterate_cb, on_error_cb) click to toggle source
# File lib/alexandria/import_library.rb, line 210
def self.import_as_isbn_list(name, filename, on_iterate_cb,
                             on_error_cb)
  log.debug { "Starting import_as_isbn_list... " }
  isbn_list = IO.readlines(filename).map do |line|
    log.debug { "Trying line #{line}" }
    [line.chomp, canonicalise_isbn(line.chomp)] unless line == "\n"
  end
  log.debug { "Isbn list: #{isbn_list.inspect}" }
  isbn_list.compact!
  return nil if isbn_list.empty?

  max_iterations = isbn_list.length * 2
  current_iteration = 1
  books = []
  bad_isbns = []
  failed_lookup_isbns = []
  isbn_list.each do |isbn|
    begin
      if isbn[1]
        books << BookProviders.isbn_search(isbn[1])
      else
        bad_isbns << isbn[0]
      end
    rescue BookProviders::SearchEmptyError => ex
      log.debug { ex.message }
      failed_lookup_isbns << isbn[1]
      log.debug { "NOTE : ignoring on_error_cb #{on_error_cb}" }
      # return nil unless
      #  (on_error_cb and on_error_cb.call(e.message))
    end

    on_iterate_cb&.call(current_iteration += 1, max_iterations)
  end
  log.debug { "Bad Isbn list: #{bad_isbns.inspect}" } if bad_isbns

  # TODO: Pass in library store as an argument
  library = LibraryCollection.instance.library_store.load_library name

  log.debug { "Going with these #{books.length} books: #{books.inspect}" }
  books.each do |book, cover_uri|
    log.debug { "Saving #{book.isbn} cover..." }
    library.save_cover(book, cover_uri) unless cover_uri.nil?
    log.debug { "Saving #{book.isbn}..." }
    library << book
    library.save(book)
    on_iterate_cb&.call(current_iteration += 1, max_iterations)
  end
  [library, bad_isbns, failed_lookup_isbns]
end
import_as_tellico_xml_archive(name, filename, on_iterate_cb, _on_error_cb) click to toggle source
# File lib/alexandria/import_library.rb, line 69
def self.import_as_tellico_xml_archive(name, filename,
                                       on_iterate_cb, _on_error_cb)
  log.debug { "Starting import_as_tellico_xml_archive... " }
  return nil unless system("unzip -qqt \"#{filename}\"")

  tmpdir = File.join(Dir.tmpdir, "tellico_export")
  FileUtils.rm_rf(tmpdir) if File.exist?(tmpdir)
  Dir.mkdir(tmpdir)
  Dir.chdir(tmpdir) do
    system("unzip -qq \"#{filename}\"")
    file = File.exist?("bookcase.xml") ? "bookcase.xml" : "tellico.xml"
    xml = REXML::Document.new(File.open(file))
    raise unless ["bookcase", "tellico"].include? xml.root.name
    # FIXME: handle multiple collections
    raise unless xml.root.elements.size == 1

    collection = xml.root.elements[1]
    raise unless collection.name == "collection"

    type = collection.attribute("type").value.to_i
    raise unless (type == 2) || (type == 5)

    content = []
    entries = collection.elements.to_a("entry")
    (total = entries.size).times do |n|
      entry = entries[n]
      elements = entry.elements
      # Feed an array in here, tomorrow.
      keys = ["isbn", "publisher", "pub_year", "binding"]

      book_elements = [neaten(elements["title"].text)]
      book_elements += if !elements["authors"].nil?
                         [elements["authors"].elements.to_a.map \
                                           { |x| neaten(x.text) }]
                       else
                         [[]]
                       end
      book_elements += keys.map do |key|
        neaten(elements[key].text) if elements[key]
      end
      book_elements[2] = Library.canonicalise_ean(book_elements[2])
      # publishing_year
      book_elements[4] = book_elements[4].to_i unless book_elements[4].nil?
      log.debug { book_elements.inspect }
      cover = (neaten(elements["cover"].text) if elements["cover"])
      log.debug { cover }
      book = Book.new(*book_elements)
      if elements["rating"]
        rating = elements["rating"].text.to_i
        book.rating = rating if Book::VALID_RATINGS.member? rating
      end
      book.notes = neaten(elements["comments"].text) if elements["comments"]
      content << [book, cover]
      on_iterate_cb&.call(n + 1, total)
    end

    # TODO: Pass in library store as an argument
    library = LibraryCollection.instance.library_store.load_library name
    content.each do |book, cover|
      library.save_cover(book, File.join(Dir.pwd, "images", cover)) unless cover.nil?
      library << book
      library.save(book)
    end
    return [library, []]
  rescue StandardError => ex
    log.info { ex.message }
    return nil
  end
end
import_autodetect(*args) click to toggle source
# File lib/alexandria/import_library.rb, line 53
def self.import_autodetect(*args)
  log.debug { args.inspect }
  filename = args[1]
  log.debug { "Filename is #{filename} and ext is #{filename[-4..-1]}" }
  log.debug { "Beginning import: #{args[0]}, #{args[1]}" }
  if filename[-4..-1] == ".txt"
    import_as_isbn_list(*args)
  elsif [".tc", ".bc"].include? filename[-3..-1]
    import_as_tellico_xml_archive(*args)
  elsif [".csv"].include? filename[-4..-1]
    import_as_csv_file(*args)
  else
    raise _("Unsupported type")
  end
end
isbn_checksum(numbers) click to toggle source
# File lib/alexandria/models/library.rb, line 85
def self.isbn_checksum(numbers)
  sum = (0...numbers.length).reduce(0) do |accumulator, i|
    accumulator + numbers[i] * (i + 1)
  end % 11

  sum == 10 ? "X" : sum
end
jpeg?(file) click to toggle source
# File lib/alexandria/models/library.rb, line 400
def self.jpeg?(file)
  IO.read(file, 10)[6..9] == "JFIF"
end
move(source_library, dest_library, *books) click to toggle source
# File lib/alexandria/models/library.rb, line 53
def self.move(source_library, dest_library, *books)
  dest = dest_library.path
  books.each do |book|
    FileUtils.mv(source_library.yaml(book), dest)
    if File.exist?(source_library.cover(book))
      FileUtils.mv(source_library.cover(book), dest)
    end

    source_library.changed
    source_library.old_delete(book)
    source_library.notify_observers(source_library,
                                    BOOK_REMOVED,
                                    book)

    dest_library.changed
    dest_library.delete_if { |book2| book2.ident == book.ident }
    dest_library << book
    dest_library.notify_observers(dest_library, BOOK_ADDED, book)
  end
end
neaten(str) click to toggle source
# File lib/alexandria/import_library.rb, line 260
def self.neaten(str)
  if str
    str.strip
  else
    str
  end
end
new(name, store = nil) click to toggle source
# File lib/alexandria/models/library.rb, line 411
def initialize(name, store = nil)
  @name = name
  @store = store
  @deleted_books = []
end
really_delete_deleted_libraries() click to toggle source
# File lib/alexandria/models/library.rb, line 263
def self.really_delete_deleted_libraries
  @@deleted_libraries.each do |library|
    FileUtils.rm_rf(library.path)
  end
end
upc_checksum(numbers) click to toggle source
# File lib/alexandria/models/library.rb, line 111
def self.upc_checksum(numbers)
  -(numbers.values_at(0, 2, 4, 6, 8, 10).sum * 3 +
    numbers.values_at(1, 3, 5, 7, 9).sum) % 10
end
upc_convert(upc) click to toggle source
# File lib/alexandria/models/library.rb, line 137
def self.upc_convert(upc)
  test_upc = upc.map(&:to_s).join
  extract_numbers(AMERICAN_UPC_LOOKUP[test_upc])
end
valid_ean?(ean) click to toggle source
# File lib/alexandria/models/library.rb, line 103
def self.valid_ean?(ean)
  numbers = extract_numbers(ean)
  ((numbers.length == 13) &&
   (ean_checksum(numbers[0..11]) == numbers[12])) ||
    ((numbers.length == 18) &&
     (ean_checksum(numbers[0..11]) == numbers[12]))
end
valid_isbn?(isbn) click to toggle source
# File lib/alexandria/models/library.rb, line 93
def self.valid_isbn?(isbn)
  numbers = extract_numbers(isbn)
  (numbers.length == 10) && isbn_checksum(numbers).zero?
end
valid_upc?(upc) click to toggle source
# File lib/alexandria/models/library.rb, line 116
def self.valid_upc?(upc)
  numbers = extract_numbers(upc)
  ((numbers.length == 17) &&
   (upc_checksum(numbers[0..10]) == numbers[11]))
end

Public Instance Methods

==(other) click to toggle source
# File lib/alexandria/models/library.rb, line 385
def ==(other)
  other.is_a?(self.class) && other.name == name
end
action_name() click to toggle source
# File lib/alexandria/ui/init.rb, line 51
def action_name
  "MoveIn" + name.gsub(/\s/, "")
end
copy_covers(somewhere) click to toggle source
# File lib/alexandria/models/library.rb, line 389
def copy_covers(somewhere)
  FileUtils.rm_rf(somewhere) if File.exist?(somewhere)
  FileUtils.mkdir(somewhere)
  each do |book|
    next unless File.exist?(cover(book))

    FileUtils.cp(cover(book),
                 File.join(somewhere, final_cover(book)))
  end
end
cover(something) click to toggle source
# File lib/alexandria/models/library.rb, line 340
def cover(something)
  ident = case something
          when Book
            if something.isbn && !something.isbn.empty?
              something.ident
            else
              "g#{something.ident}" # g is for generated id...
            end
          when String
            something
          when Integer
            something
          else
            raise NotImplementedError
          end
  File.join(path, ident.to_s + EXT[:cover])
end
delete(book = nil) click to toggle source
# File lib/alexandria/models/library.rb, line 278
def delete(book = nil)
  if book.nil?
    # Delete the whole library.
    raise if @@deleted_libraries.include?(self)

    @@deleted_libraries << self
  else
    if @deleted_books.include?(book)
      doubles = @deleted_books.select { |b| b.equal?(book) }
      unless doubles.empty?
        raise ArgumentError, format(_("Book %<isbn>s was already deleted"),
                                    isbn: book.isbn)
      end
    end
    @deleted_books << book
    i = index(book)
    # We check object IDs there because the user could have added
    # a book with the same identifier as another book he/she
    # previously deleted and that he/she is trying to redo.
    if i && self[i].equal?(book)
      changed
      old_delete(book) # FIX this will old_delete all '==' books
      notify_observers(self, BOOK_REMOVED, book)
    end
  end
end
Also aliased as: old_delete
deleted?() click to toggle source
# File lib/alexandria/models/library.rb, line 305
def deleted?
  @@deleted_libraries.include?(self)
end
final_cover(book) click to toggle source
# File lib/alexandria/models/library.rb, line 404
def final_cover(book)
  # TODO: what about PNG?
  book.ident + (Library.jpeg?(cover(book)) ? ".jpg" : ".gif")
end
n_rated() click to toggle source
# File lib/alexandria/models/library.rb, line 377
def n_rated
  count { |x| !x.rating.nil? && x.rating > 0 }
end
n_unrated() click to toggle source
# File lib/alexandria/models/library.rb, line 381
def n_unrated
  length - n_rated
end
name=(name) click to toggle source
# File lib/alexandria/models/library.rb, line 372
def name=(name)
  File.rename(path, File.join(dir, name))
  @name = name
end
old_cover(book) click to toggle source
# File lib/alexandria/models/library.rb, line 336
def old_cover(book)
  File.join(path, book.ident.to_s + EXT[:cover])
end
old_delete(book = nil)
Alias for: delete
old_select()
Alias for: select
path() click to toggle source
# File lib/alexandria/models/library.rb, line 31
def path
  File.join(@store.library_dir, @name)
end
really_delete_deleted_books() click to toggle source
# File lib/alexandria/models/library.rb, line 269
def really_delete_deleted_books
  @deleted_books.each do |book|
    [yaml(book), cover(book)].each do |file|
      FileUtils.rm_f(file)
    end
  end
end
save(book, final = false) click to toggle source
# File lib/alexandria/models/library.rb, line 198
def save(book, final = false)
  changed unless final

  # Let's initialize the saved identifier if not already
  # (backward compatibility from 0.4.0).
  book.saved_ident ||= book.ident

  if book.ident != book.saved_ident
    FileUtils.rm(yaml(book.saved_ident))
    if File.exist?(cover(book.saved_ident))
      FileUtils.mv(cover(book.saved_ident), cover(book.ident))
    end

    # Notify before updating the saved identifier, so the views
    # can still use the old one to update their models.
    notify_observers(self, BOOK_UPDATED, book) unless final
    book.saved_ident = book.ident
  end
  # #was File.exist? but that returns true for empty files... CathalMagus
  already_there = (File.size?(yaml(book)) &&
                   !@deleted_books.include?(book))

  temp_book = book.dup
  temp_book.library = nil
  File.open(yaml(temp_book), "w") { |io| io.puts temp_book.to_yaml }

  # Do not notify twice.
  return unless changed?

  notify_observers(self,
                   already_there ? BOOK_UPDATED : BOOK_ADDED,
                   book)
end
save_cover(book, cover_uri) click to toggle source
# File lib/alexandria/models/library.rb, line 237
def save_cover(book, cover_uri)
  Dir.chdir(path) do
    # Fetch the cover picture.
    cover_file = cover(book)
    File.open(cover_file, "w") do |io|
      uri = URI.parse(cover_uri)
      if uri.scheme.nil?
        # Regular filename.
        File.open(cover_uri) { |io2| io.puts io2.read }
      else
        # Try open-uri.
        io.puts transport.get(uri)
      end
    end

    # Remove the file if its blank.
    File.delete(cover_file) if Alexandria::UI::Icons.blank?(cover_file)
  end
end
select() { |book| ... } click to toggle source
# File lib/alexandria/models/library.rb, line 328
def select
  filtered_library = Library.new(@name)
  each do |book|
    filtered_library << book if yield(book)
  end
  filtered_library
end
Also aliased as: old_select
simple_save(book) click to toggle source
# File lib/alexandria/models/library.rb, line 180
def simple_save(book)
  # Let's initialize the saved identifier if not already
  # (backward compatibility from 0.4.0)
  # book.saved_ident ||= book.ident
  book.saved_ident = book.ident if book.saved_ident.nil? || book.saved_ident.empty?
  if book.ident != book.saved_ident
    FileUtils.rm(yaml(book.saved_ident))
    if File.exist?(cover(book.saved_ident))
      FileUtils.mv(cover(book.saved_ident), cover(book.ident))
    end
  end
  book.saved_ident = book.ident

  filename = book.saved_ident.to_s + ".yaml"
  File.open(filename, "w") { |io| io.puts book.to_yaml }
  filename
end
transport() click to toggle source
# File lib/alexandria/models/library.rb, line 232
def transport
  config = Alexandria::Preferences.instance.http_proxy_config
  config ? Net::HTTP.Proxy(*config) : Net::HTTP
end
undelete(book = nil) click to toggle source
# File lib/alexandria/models/library.rb, line 309
def undelete(book = nil)
  if book.nil?
    # Undelete the whole library.
    raise unless @@deleted_libraries.include?(self)

    @@deleted_libraries.delete(self)
  else
    raise unless @deleted_books.include?(book)

    @deleted_books.delete(book)
    unless include?(book)
      changed
      self << book
      notify_observers(self, BOOK_ADDED, book)
    end
  end
end
updating?() click to toggle source
# File lib/alexandria/models/library.rb, line 35
def updating?
  @updating
end
yaml(something, basedir = path) click to toggle source
# File lib/alexandria/models/library.rb, line 358
def yaml(something, basedir = path)
  ident = case something
          when Book
            something.ident
          when String
            something
          when Integer
            something
          else
            raise NotImplementedError
          end
  File.join(basedir, ident.to_s + EXT[:book])
end