From 5cdb0d025521ac43a5d090d60c044d386d53b753 Mon Sep 17 00:00:00 2001 From: Chris Jaekl Date: Sat, 22 Jun 2024 18:51:39 -0400 Subject: [PATCH] Address the low-hanging-fruit RuboCop advisories This leaves 50 "offenses" still to be examined. Most of those are complaints about code complexity, and will require some thought before attempting a refactor. --- .rubocop.yml | 17 ++ Gemfile | 18 +- app/author.rb | 18 +- app/book.rb | 397 +++++++++++++--------------- app/book_loader.rb | 16 +- app/classification.rb | 14 +- app/cover.rb | 45 ++-- app/extract.rb | 40 ++- app/main.rb | 63 +++-- app/navigator.rb | 140 +++++----- app/page.rb | 224 +++++++--------- app/series.rb | 15 +- app/store.rb | 510 ++++++++++++++++++------------------ app/tconn.rb | 59 ++--- app/walk_dir.rb | 97 +++---- test/book_test.rb | 17 +- test/classification_test.rb | 6 +- test/conn_mock.rb | 8 +- test/store_mock.rb | 4 +- test/store_test.rb | 16 +- test/walk_dir_test.rb | 100 +++---- 21 files changed, 871 insertions(+), 953 deletions(-) create mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..9bbd52c --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,17 @@ +AllCops: + TargetRubyVersion: 3.0 + +Metrics/MethodLength: + Max: 25 + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes diff --git a/Gemfile b/Gemfile index 0181479..4f41955 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,11 @@ -source 'http://rubygems.org' +# frozen_string_literal: true -gem 'inifile' -gem 'mocha' -gem 'nokogiri' -gem 'pg' -gem 'rspec' -gem 'rubocop' -gem 'rubyzip' +source "http://rubygems.org" + +gem "inifile" +gem "mocha" +gem "nokogiri" +gem "pg" +gem "rspec" +gem "rubocop" +gem "rubyzip" diff --git a/app/author.rb b/app/author.rb index 781969c..716f5aa 100644 --- a/app/author.rb +++ b/app/author.rb @@ -1,18 +1,15 @@ # frozen_string_literal: true +# Encapsulates information about an author class Author - attr_accessor :grouping - attr_accessor :reading_order - attr_accessor :sort_order + attr_accessor :grouping, :reading_order, :sort_order def initialize(grouping, reading_order, sort_order) @grouping = grouping @reading_order = reading_order @sort_order = sort_order - if (nil == sort_order) || ('Unknown' == sort_order) - @sort_order = reading_to_sort_order(reading_order) - end + @sort_order = reading_to_sort_order(reading_order) if sort_order.nil? || sort_order == "Unknown" end def inspect @@ -34,12 +31,9 @@ class Author def reading_to_sort_order(reading_order) sort_order = reading_order - parts = reading_order.split(' ') - if parts.length > 1 - sort_order = parts[-1] + ', ' + parts[0..-2].join(' ') - end + parts = reading_order.split(" ") + sort_order = "#{parts[-1]}, #{parts[0..-2].join(" ")}" if parts.length > 1 - return sort_order + sort_order end end - diff --git a/app/book.rb b/app/book.rb index c4d7070..3449b6d 100644 --- a/app/book.rb +++ b/app/book.rb @@ -1,106 +1,87 @@ +# frozen_string_literal: true -require 'nokogiri' -require 'rubygems' -require 'zip' +require "nokogiri" +require "rubygems" +require "zip" -require_relative 'author' -require_relative 'classification' -require_relative 'cover' -require_relative 'store' +require_relative "author" +require_relative "classification" +require_relative "cover" +require_relative "store" +# Encapsulates info about a book in the library class Book - @@DC_NS_URL = 'http://purl.org/dc/elements/1.1/' - @@SERIES_AND_VOLUME_REGEX = /^([A-Z]+)([0-9]+(\.[0-9]+)?)$/ - - attr_accessor :arrived - attr_accessor :author - attr_accessor :classification_id - attr_accessor :cover - attr_accessor :description - attr_accessor :language - attr_accessor :path - attr_accessor :series_id - attr_accessor :title - attr_accessor :volume + @@dc_ns_url = "http://purl.org/dc/elements/1.1/" + @@series_and_volume_regex = /^([A-Z]+)([0-9]+(\.[0-9]+)?)$/ + + attr_accessor( + :arrived, + :author, + :classification_id, + :cover, + :description, + :language, + :path, + :series_id, + :title, + :volume + ) def initialize(store) @store = store end - def load_from_file!(fileName) - @path = fileName - parse_file_name!(fileName) + def load_from_file!(file_name) + @path = file_name + parse_file_name!(file_name) end - def self.can_handle?(fileName) - if nil == fileName - return false - end + def self.can_handle?(file_name) + return false if file_name.nil? - #puts "Filename: " + fileName.to_s - lowerName = fileName.downcase() + lower_name = file_name.downcase - if lowerName.end_with?(".epub") - return true - end + return true if lower_name.end_with?(".epub") - if lowerName.end_with?(".pdf") - return true - end + return true if lower_name.end_with?(".pdf") - return false + false end def self.grouping_for_title(title) result = title - '\'",!#'.split('').each do |c| - result = result.gsub(c, '-') + "'\",!#".split("").each do |c| + result = result.gsub(c, "-") end - result = result.gsub(/: */, '--') - result = result.gsub(' ', '_') - result + result.gsub(/: */, "--").gsub(" ", "_") end def heading result = [] - if nil != @title - result.push('' + @title + '') + if @title.nil? + result.push("(Unknown title)") else - result.push('(Unknown title)') - end - if nil != @author - result.push('by ' + @author.reading_order + '') + result.push("#{@title}") end + result.push("by #{@author.reading_order}") unless @author.nil? - seriesInfo = [] + series_info = [] series = @store.load_series(@series_id) - if nil != series and nil != series.descr - seriesInfo.push(series.descr.to_s) - end - if nil != @volume - seriesInfo.push(@volume.to_s) - end - if seriesInfo.length > 0 - result.push(seriesInfo.join(' ')) - end + series_info.push(series.descr.to_s) if series&.descr + series_info.push(@volume.to_s) unless @volume.nil? + result.push(series_info.join(" ")) unless series_info.empty? classification = nil - if nil != @classification_id - classification = @store.load_classification(@classification_id) - end - if nil != classification - if nil != classification.ddc - result.push('Dewey: ' + classification.ddc.to_s) - end - if nil != classification.lcc - result.push('LCC: ' + classification.lcc.to_s) - end + classification = @store.load_classification(@classification_id) unless @classification_id.nil? + unless classification.nil? + result.push("Dewey: #{classification.ddc}") unless classification.ddc.nil? + result.push("LCC: #{classification.lcc}") unless classification.lcc.nil? end - return result.join('
') + result.join("
") end def inspect @@ -113,35 +94,31 @@ class Book path.nil? ? nil : "path=\"#{path.inspect}", ].compact.join(" ") - return "(Book: #{field_info})" + "(Book: #{field_info})" end def to_s - return inspect() + inspect end def title_grouping return if path.nil? - File.basename(@path, '.*') + File.basename(@path, ".*") end private - def isUpper?(c) - /[[:upper:]]/.match(c) + def upper?(character) + /[[:upper:]]/.match(character) end def massage_author(input) - if nil == input - return nil - end + return if input.nil? reading_order = "" input.each_char do |c| - if isUpper?(c) and (reading_order.length > 0) - reading_order += " " - end + reading_order += " " if upper?(c) && !reading_order.empty? reading_order += c end @@ -149,50 +126,48 @@ class Book end # Returns (series, volumeNo, titleText) - def processTitle(input) - if nil == input - return nil - end + def process_title(input) + return if input.nil? - arr = input.split('_') + arr = input.split("_") series = nil vol = nil first = arr[0] - matchData = (first).match(@@SERIES_AND_VOLUME_REGEX) - if nil != matchData - capt = matchData.captures + match_data = first.match(@@series_and_volume_regex) + unless match_data.nil? + capt = match_data.captures series = capt[0] vol = capt[1] arr.shift end - pos = arr[-1].rindex('.') - if nil != pos - arr[-1] = arr[-1].slice(0, pos) - end + pos = arr[-1].rindex(".") + arr[-1] = arr[-1].slice(0, pos) unless pos.nil? - title = arr.join(' ') + title = arr.join(" ") - bare_title_grouping = title_grouping - .split('_') - .reject { |part| part.match(@@SERIES_AND_VOLUME_REGEX) } - .join('_') + bare_title_grouping = + title_grouping + .split("_") + .reject { |part| part.match(@@series_and_volume_regex) } + .join("_") unless bare_title_grouping == Book.grouping_for_title(title) - puts "WARNING: title_grouping mismatch: #{bare_title_grouping.inspect} vs. #{Book.grouping_for_title(title).inspect}" + discrepancy = "#{bare_title_grouping.inspect} vs. #{Book.grouping_for_title(title).inspect}" + puts "WARNING: title_grouping mismatch: #{discrepancy}" end - return series, vol, title + [series, vol, title] end def parse_file_name!(file_name) - category = nil # e.g., non-fiction, fan-fiction - grouping = '' + category = nil # e.g., non-fiction, fan-fiction + grouping = "" - parts = file_name.split('/') - (series_code, @volume, @title) = processTitle(parts[-1]) + parts = file_name.split("/") + (series_code, @volume, @title) = process_title(parts[-1]) if parts.length > 1 grouping = parts[-2] reading_order = massage_author(grouping) @@ -200,72 +175,68 @@ class Book @author = Author.new(grouping, reading_order, sort_order) @series_id = @store.get_series(grouping, series_code) end - if parts.length > 2 - category = parts[-3] - end + category = parts[-3] if parts.length > 2 lc_file_name = file_name.downcase if lc_file_name.end_with?(".epub") - scanEpub!(file_name) + scan_epub!(file_name) elsif lc_file_name.end_with?(".pdf") scan_pdf!(file_name) end @arrived = File.ctime(file_name) - @classification_id = @store.find_classification(@author.grouping, File.basename(file_name, '.*')) + @classification_id = @store.find_classification(@author.grouping, File.basename(file_name, ".*")) # TODO: Fix horrible hard-coded strings and paths - if ('01_nonfic' == category) && (nil == classification_id) - open(Store.unclassified_csv, 'a') do |fd| - fd.puts('"' + grouping.to_s + '","' + path + '"') - end + return unless category == "00_nonFic" && classification_id.nil? + + File.open(Store.unclassified_csv, "a") do |fd| + fd.puts "#{grouping.inspect},#{path.inspect}" end end - def scanEpub!(fileName) - #puts 'Scanning "' + fileName.to_s + '"...' - begin - Zip.warn_invalid_date = false - Zip::File.open(fileName) do |zipfile| - entry = zipfile.find_entry('META-INF/container.xml') - if nil == entry - puts 'No META-INF/container.xml, skipping book ' + fileName - return - end - contXml = zipfile.read('META-INF/container.xml') - contDoc = Nokogiri::XML(contXml) - opfPath = contDoc.css("container rootfiles rootfile")[0]['full-path'] - - scanOpf!(zipfile, opfPath) + def scan_epub!(file_name) + Zip.warn_invalid_date = false + Zip::File.open(file_name) do |zipfile| + entry = zipfile.find_entry("META-INF/container.xml") + if entry.nil? + puts "No META-INF/container.xml, skipping book #{file_name.inspect}" + return nil end - rescue Zip::Error => exc - puts 'ERROR processing file "' + fileName + '":' - puts exc.message - puts exc.backtrace + cont_xml = zipfile.read("META-INF/container.xml") + cont_doc = Nokogiri::XML(cont_xml) + opf_path = cont_doc.css("container rootfiles rootfile")[0]["full-path"] + + scan_opf!(zipfile, opf_path) + rescue Zip::Error => e + puts "ERROR processing file #{file_name.inspect}:" + puts e.message + puts e.backtrace end end def scan_pdf!(file_name) pdf_path = File.expand_path(file_name).to_s - if ! pdf_path.end_with?('.pdf') - puts 'Unexpected internal error: path "' + file_name.to_s + '" does not end with ".pdf".' + unless pdf_path.end_with?(".pdf") + puts "Unexpected internal error: path #{file_name.inspect} does not end with \".pdf\"." return end - jpeg_path = pdf_path[0..-5] + '.jpeg' - if File.file?(jpeg_path) - File.open(jpeg_path, 'r') do |is| - @cover = Cover.new(is, jpeg_path, 'image/jpeg') - end + jpeg_path = "#{pdf_path[0..-5]}.jpeg" + + return unless File.file?(jpeg_path) + + File.open(jpeg_path, "r") do |is| + @cover = Cover.new(is, jpeg_path, "image/jpeg") end end - def scanOpf!(zipfile, opfPath) - coverId = nil + def scan_opf!(zipfile, opf_path) + cover_id = nil - opfXml = zipfile.read(opfPath) - opfDoc = Nokogiri::XML(opfXml) + opf_xml = zipfile.read(opf_path) + opf_doc = Nokogiri::XML(opf_xml) #------- # Author @@ -274,122 +245,111 @@ class Book reading_order = @author.reading_order sort_order = @author.sort_order - creators = opfDoc.css('dc|creator', 'dc' => @@DC_NS_URL) - if (creators.length > 0) + creators = opf_doc.css("dc|creator", "dc" => @@dc_ns_url) + unless creators.empty? creator = creators[0] - if nil != creator - role = creator['opf:role'] - if 'aut' == role - reading_order = creator.content - - file_as = creator['opf:file-as'] - if nil != file_as - sort_order = file_as - end - end - @author = Author.new(grouping, reading_order, sort_order) + return if creator.nil? + + role = creator["opf:role"] + if role == "aut" + reading_order = creator.content + + file_as = creator["opf:file-as"] + sort_order = file_as unless file_as.nil? end + + @author = Author.new(grouping, reading_order, sort_order) end #--------------------------------------- # Title - titles = opfDoc.css('dc|title', 'dc' => @@DC_NS_URL) - if titles.length > 0 + titles = opf_doc.css("dc|title", "dc" => @@dc_ns_url) + unless titles.empty? title = titles[0] - if nil != title - @title = title.content - end + @title = title.content unless title.nil? end #--------------------------------------- # Description - descrNodes = opfDoc.css('dc|description', 'dc' => @@DC_NS_URL) - if (descrNodes.length > 0) - descrNode = descrNodes[0] - if nil != descrNode - @description = descrNode.content - end + descr_nodes = opf_doc.css("dc|description", "dc" => @@dc_ns_url) + unless descr_nodes.empty? + descr_node = descr_nodes[0] + @description = descr_node.content unless descr_node.nil? end #--------------------------------------- # Language - langNodes = opfDoc.css('dc|language', 'dc' => @@DC_NS_URL) - if (langNodes.length > 0) - langNode = langNodes[0] - if langNode - @language = langNode.content - end + lang_nodes = opf_doc.css("dc|language", "dc" => @@dc_ns_url) + unless lang_nodes.empty? + lang_node = lang_nodes[0] + @language = lang_node.content if lang_node end #--------------------------------------- # Other metadata: series, volume, cover - metas = opfDoc.css('package metadata meta') - for m in metas - name = m['name'] - content = m['content'] + metas = opf_doc.css("package metadata meta") + metas.each do |m| + name = m["name"] + content = m["content"] - if 'calibre:series' == name + case name + when "calibre:series" # TODO: Dynamically create a new series? # @series_id = content - elsif 'calibre:series-index' == name + when "calibre:series-index" @volume = content - elsif 'cover' == name - coverId = content - #puts 'File ' + @path + ' coverId ' + coverId + when "cover" + cover_id = content end end #--------------- # Load the cover - @cover = load_cover(zipfile, opfPath, opfDoc, coverId) + @cover = load_cover(zipfile, opf_path, opf_doc, cover_id) end - def load_cover(zipfile, opfPath, opfDoc, coverId) - if nil == coverId - coverId = "cover-image" - end + def load_cover(zipfile, opf_path, opf_doc, cover_id) + cover_id = "cover-image" if cover_id.nil? - items = opfDoc.css('package manifest item') - for i in items - href = i['href'] - id = i['id'] - mimeType = i['media-type'] - - if coverId == id - entry = zipfile.find_entry(href) - - if nil == entry - # Although the epub standard requires the path to be relative - # to the base of the epub (zip), some books encountered in the - # wild have been found to use a bath relative to the location - # of the opf file. - parts = opfPath.split('/') - opfBasePath = parts[0..-2].join('/') - coverPath = opfBasePath + '/' + href - entry = zipfile.find_entry(coverPath) - end + items = opf_doc.css("package manifest item") + items.each do |i| + href = i["href"] + id = i["id"] + mime_type = i["media-type"] - unless entry - # Another case found in the wild: cover image is at the root, but path is '../cover.jpeg' - if href.start_with? '../' - coverPath = href[3..-1] - entry = zipfile.find_entry(coverPath) - end - end + next unless cover_id == id - if nil == entry - puts 'WARNING! Cover image "' + href + '" not found in file "' + @path + '".' - return nil - else - entry.get_input_stream() do |is| - return Cover.new(is, href, mimeType) - end + entry = zipfile.find_entry(href) + + if entry.nil? + # Although the epub standard requires the path to be relative + # to the base of the epub (zip), some books encountered in the + # wild have been found to use a bath relative to the location + # of the opf file. + parts = opf_path.split("/") + opf_base_path = parts[0..-2].join("/") + cover_path = "#{opf_base_path}/#{href}" + entry = zipfile.find_entry(cover_path) + end + + if !entry && href.start_with?("../") + # Another case found in the wild: cover image is at the root, but path is '../cover.jpeg' + cover_path = href[3..] + entry = zipfile.find_entry(cover_path) + end + + if entry.nil? + puts "WARNING! Cover image #{href.inspect} not found in file #{@path.inspect}." + return nil + else + entry.get_input_stream do |is| + return Cover.new(is, href, mime_type) end end end @@ -397,4 +357,3 @@ class Book nil end end - diff --git a/app/book_loader.rb b/app/book_loader.rb index 07eb853..ca963ff 100644 --- a/app/book_loader.rb +++ b/app/book_loader.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true -require_relative 'book' -require_relative 'store' +require_relative "book" +require_relative "store" -class BookLoader - DONE_MARKER = '' +# Worker thread that pulls filenames from a queue and loads them as new books +class BookLoader + DONE_MARKER = "" def initialize(config_file, queue) @config_file = config_file @@ -13,10 +14,11 @@ class BookLoader def run @store = Store.new(@config_file) - @store.connect() + @store.connect file = @queue.pop - until file == DONE_MARKER do + + until file == DONE_MARKER book = Book.new(@store) book.load_from_file!(file) @store.store_book(book) @@ -24,6 +26,6 @@ class BookLoader file = @queue.pop end - @store.disconnect() + @store.disconnect end end diff --git a/app/classification.rb b/app/classification.rb index 5eb6f7f..c9055a1 100644 --- a/app/classification.rb +++ b/app/classification.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# Encapsulates information about a book's classification, under the +# Dewey Decimal and/or Library of Congress systems. class Classification attr_accessor :id, :ddc, :lcc, :author_grouping, :author, :title_grouping, :title @@ -23,7 +25,7 @@ class Classification title.nil? ? nil : "title=#{title.inspect}", ].compact.join(", ") - return "(Classification: #{field_info})" + "(Classification: #{field_info})" end def to_s @@ -35,12 +37,10 @@ class Classification def reading_to_sort_order(reading_order) sort_order = reading_order - parts = reading_order.split(' ') - if parts.length > 1 - sort_order = parts[-1] + ', ' + parts[0..-2].join(' ') - end + parts = reading_order.split(" ") - return sort_order + sort_order = [parts[-1], parts[0..-2].join(" ")].join(", ") if parts.length > 1 + + sort_order end end - diff --git a/app/cover.rb b/app/cover.rb index e3dbad4..ba3c570 100644 --- a/app/cover.rb +++ b/app/cover.rb @@ -1,55 +1,46 @@ # frozen_string_literal: true +# Encapsulates information about a book cover class Cover attr_reader :path - def initialize(inputStream, path, mimeType) - if nil != inputStream - @data = inputStream.read - else - @data = nil - end + def initialize(input_stream, path, mime_type) + @data = + if input_stream.nil? + nil + else + input_stream.read + end + @path = path - @mimeType = mimeType + @mime_type = mime_type end def inspect field_info = [ - @data.nil? ? nil : "size=#{@data.length.to_s}", + @data.nil? ? nil : "size=#{@data.length}", @path.nil? ? nil : "path=#{@path.inspect}", - @mimeType.nil? ? nil : "mime_type=#{@mimeType.inspect}", + @mime_type.nil? ? nil : "mime_type=#{@mime_type.inspect}", ].compact.join(" ") "(Cover: #{field_info})" end def read_image(filename) - open(filename, 'rb') do |fd| - @data = fd.read() + File.open(filename, "rb") do |fd| + @data = fd.read end end def to_s - return inspect + inspect end - def write_image(outputDir, filename) - open(outputDir + '/' + filename, 'wb') do |fd| + def write_image(output_dir, filename) + File.open("#{output_dir}/#{filename}", "wb") do |fd| fd.write(@data) end - return filename, @mimeType - end - - private - - def getExt - pos = @path.rindex('.') - if nil == pos - return '.img' - end - - @path.slice(pos, @path.length) + [filename, @mime_type] end end - diff --git a/app/extract.rb b/app/extract.rb index c695941..13854ea 100644 --- a/app/extract.rb +++ b/app/extract.rb @@ -1,50 +1,44 @@ -require 'find' -require 'pathname' +# frozen_string_literal: true + +require "find" +require "pathname" def exec(cmdline) puts "$ #{cmdline}" result = system(cmdline) - unless result - puts "FAILED: #{cmdline}" - end + puts "FAILED: #{cmdline}" unless result result end def extract_epub(source_file, source_path, dest_path) - relative_path = source_file[source_path.length .. source_file.length] + relative_path = source_file[source_path.length..source_file.length] dest_file = "#{dest_path}/#{relative_path}" - dest_file = dest_file[0 .. (dest_file.length - 6)] + ".txt" + dest_file = "#{dest_file[0..(dest_file.length - 6)]}.txt" required_path = Pathname(dest_file).dirname - unless File.directory? required_path - unless exec("mkdir -p #{required_path}") - return false - end - end + + return false unless File.directory?(required_path) || exec("mkdir -p #{required_path}") if File.exist? dest_file source_time = File.mtime source_file dest_time = File.mtime dest_file comp = dest_time <=> source_time - if comp > 0 - return true # Nothing to do, extraction is already up-to-date - end + return true if comp.positive? # Nothing to do, extraction is already up-to-date end - + exec("ebook-convert #{source_file} #{dest_file}") end -def scan_dir(source_path, dest_path) +def scan_dir(source_path, dest_path) Find.find(source_path) do |f| - if f.match(/.epub\Z/) - unless (f.match(/_bis.epub\Z/) || f.match(/_ter.epub\Z/) || f.match(/_quater.epub\Z/)) - extract_epub(f, source_path, dest_path) - end - end + next unless f.match(/.epub\Z/) + next if f.match(/_bis.epub\Z/) || f.match(/_ter.epub\Z/) || f.match(/_quater.epub\Z/) + + extract_epub(f, source_path, dest_path) end end dest_path = ARGV[0] -for arg in ARGV[1 .. ARGV.length] +ARGV[1..ARGV.length].each do |arg| scan_dir(arg, dest_path) end diff --git a/app/main.rb b/app/main.rb index 1eee8b4..abc4601 100644 --- a/app/main.rb +++ b/app/main.rb @@ -1,58 +1,55 @@ # frozen_string_literal: true -require_relative 'navigator' -require_relative 'page' -require_relative 'store' -require_relative 'walk_dir' +require_relative "navigator" +require_relative "page" +require_relative "store" +require_relative "walk_dir" -@outputDir = 'output' +@output_dir = "output" -@config_file = 'quanlib.ini' +@config_file = "quanlib.ini" @skip_class = false -def handleArg(arg) +def handle_arg(arg) if arg.start_with?("--config=") - @config_file = arg[9..-1] - puts 'Using config file "' + @config_file + '".' - elsif "--purge" == arg - puts 'Purging database...' - @store.dropSchema() - if File.exists?(@store.unclassified_csv) - File.delete(@store.unclassified_csv) - end - elsif "--skip-class" == arg - puts 'Skipping load of classification table.' + @config_file = arg[9..] + puts "Using config file #{@config_file.inspect}." + elsif arg == "--purge" + puts "Purging database..." + @store.drop_schema + File.delete(@store.unclassified_csv) if File.exist?(@store.unclassified_csv) + elsif arg == "--skip-class" + puts "Skipping load of classification table." @skip_class = true elsif arg.start_with?("--") - abort('ERROR: Unrecognized option "' + arg + '".') + abort("ERROR: Unrecognized option #{arg.inspect}.") end end @store = Store.new(@config_file) -@store.connect() +@store.connect -for arg in ARGV - handleArg(arg) +ARGV.each do |arg| + handle_arg(arg) end @store.init_db(@skip_class) -for arg in ARGV - if ! arg.start_with?("--") - puts 'Scanning directory "' + arg + '"...' - w = WalkDir.new(@config_file, arg) - w.books - end +ARGV.each do |arg| + next if arg.start_with?("--") + + puts "Scanning directory #{arg.inspect}..." + w = WalkDir.new(@config_file, arg) + w.books end @store.cross_reference_lists -puts 'Creating output...' +puts "Creating output..." navigator = Navigator.new(@store) -navigator.write_atoz_pages() -navigator.write_series_listing() -navigator.write_dewey() - -@store.disconnect() +navigator.write_atoz_pages +navigator.write_series_listing +navigator.write_dewey +@store.disconnect diff --git a/app/navigator.rb b/app/navigator.rb index 881b1fa..df0f4e1 100644 --- a/app/navigator.rb +++ b/app/navigator.rb @@ -1,6 +1,9 @@ -require_relative 'page' -require_relative 'store' +# frozen_string_literal: true +require_relative "page" +require_relative "store" + +# Generates navigation pages for the static HTML output of the library index class Navigator def initialize(store) @store = store @@ -9,55 +12,52 @@ class Navigator def write_atoz_pages atoz_counts = {} - ('A'..'Z').each do |letter| + ("A".."Z").each do |letter| atoz_counts[letter] = write_authors_starting_with(letter) end - content = '

' - ('A'..'Z').each do |letter| - content += ' ' + content = "

AuthorBooks
Starting with ' + letter + '' + atoz_counts[letter].to_s + '
" + ("A".."Z").each do |letter| + link = "Starting with #{letter}" + content += " " end - content += '
AuthorBooks
#{link}#{atoz_counts[letter]}

' + content += "

" page = Page.new(@store) - page.output_dir = 'atoz' + page.output_dir = "atoz" page.special = content - page.up = ['../output/index.html', 'Up'] + page.up = ["../output/index.html", "Up"] - page.write_html( [] ) + page.write_html([]) end def write_authors_starting_with(letter) - book_ids = @store.query_books_by_author(letter + '%') - puts 'Authors starting with "' + letter + '": ' + book_ids.length.to_s() + ' books.' + book_ids = @store.query_books_by_author("#{letter}%") + puts "Authors starting with #{letter.inspect}: #{book_ids.length} books." page = Page.new(@store) - if 'A' != letter - page.back = ['../atoz/output_' + (letter.ord - 1).chr + '.html', 'Prev'] - end - if 'Z' != letter - page.forward = ['../atoz/output_' + (letter.ord + 1).chr + '.html', 'Next'] - end - page.output_dir = 'atoz' - page.index_file = 'output_' + letter + '.html' - page.title = "Authors starting with '" + letter + "'" - page.up = ['../atoz/index.html', 'Up'] + page.back = ["../atoz/output_#{(letter.ord - 1).chr}.html", "Prev"] if letter != "A" + page.forward = ["../atoz/output_#{(letter.ord + 1).chr}.html", "Next"] if letter != "Z" + page.output_dir = "atoz" + page.index_file = "output_#{letter}.html" + page.title = "Authors starting with #{letter.inspect}" + page.up = ["../atoz/index.html", "Up"] page.write_html(book_ids) - return book_ids.length + book_ids.length end def write_dewey - book_ids = @store.query_books_by_ddc() - puts 'Non-fiction books arranged by Dewey Decimal Classification: ' + book_ids.length.to_s() + ' books.' + book_ids = @store.query_books_by_ddc + puts "Non-fiction books arranged by Dewey Decimal Classification: #{book_ids.length} books." page = Page.new(@store) - page.output_dir = 'ddc' - page.index_file = 'index.html' + page.output_dir = "ddc" + page.index_file = "index.html" page.title = "Non-fiction books arranged by Dewey Decimal call number" - page.up = ['../output/index.html', 'Up'] - + page.up = ["../output/index.html", "Up"] + page.write_html(book_ids) - return book_ids.length + book_ids.length end def write_series_for_age(age) @@ -68,45 +68,33 @@ class Navigator series_ids.each do |id| series = @store.load_series(id) book_ids = @store.query_books_by_series_id(id) - if nil != book_ids and book_ids.length > 0 - series_infos.push( [series, book_ids] ) - end + series_infos.push([series, book_ids]) unless book_ids&.empty? end - for idx in 0 .. (series_infos.length - 1) do - #puts series.descr + ': ' + book_ids.length.to_s + ' books.' - + 0..(series_infos.length - 1).each do |idx| back = nil fwd = nil - if idx > 0 - back = series_infos[idx-1] - end - if (idx + 1) < series_infos.length - fwd = series_infos[idx+1] - end + back = series_infos[idx - 1] if idx.positive? + fwd = series_infos[idx + 1] if (idx + 1) < series_infos.length cur = series_infos[idx] series = cur[0] book_ids = cur[1] page = Page.new(@store) - if nil != back - page.back = [back[0].key + '.html', 'Back'] - end - if nil != fwd - page.forward = [fwd[0].key + '.html', 'Forward'] - end - page.output_dir = 'series/series_' + age - page.index_file = series.key + '.html' - page.title = 'Series “' + series.descr + '” (' + book_ids.length.to_s + ' books)' - page.up = ['index.html', 'Up'] - + page.back = ["#{back[0].key}.html", "Back"] unless back.nil? + page.forward = ["#{fwd[0].key}.html", "Forward"] unless fwd.nil? + page.output_dir = "series/series_#{age}" + page.index_file = "#{series.key}.html" + page.title = "Series “#{series.descr}” (#{book_ids.length} books)" + page.up = ["index.html", "Up"] + page.write_html(book_ids) end - content = '

“' + age + '” Series

' - content += '

' + content = "

“#{age}” Series

" + content += "

AuthorSeriesGenreBooks
" series_infos.each do |cur| series = cur[0] book_ids = cur[1] @@ -114,44 +102,44 @@ class Navigator author = series.grouping letter = author[0] - content += ' ' - content += '' - content += '' - content += '' - content += '' - content += '' + content += " " + content += "" + content += "" + content += "" + content += "" + content += "" end - content += '
AuthorSeriesGenreBooks
' + author + '' + series.descr + '' + series.genre + '' + book_ids.length.to_s + '
#{author}#{series.descr}#{series.genre}#{book_ids.length}

' + content += "

" page = Page.new(@store) - page.output_dir = 'series/series_' + age + page.output_dir = "series/series_#{age}" page.special = content - page.up = ['../index.html', 'Up'] - page.write_html( [] ) + page.up = ["../index.html", "Up"] + page.write_html([]) - return series_infos.length + series_infos.length end def write_series_listing - ages = ['beginner', 'junior', 'ya', 'adult'] + ages = %w[beginner junior ya adult] series_counts = {} ages.each do |age| - puts 'Series for "' + age + '" readers...' + puts "Series for #{age.inspect} readers..." series_counts[age] = write_series_for_age(age) end - content = '

Browse Books By Series

' - content += '

' - content += '' + content = "

Browse Books By Series

" + content += "

" + content += "

AgeNumber of Series
" ages.each do |age| - content += '' + content += "" end - content += '
AgeNumber of Series
' + age + '' + series_counts[age].to_s + '
#{age}#{series_counts[age]}

' + content += "

" page = Page.new(@store) - page.output_dir = 'series' + page.output_dir = "series" page.special = content - page.up = ['../output/index.html', 'Up'] - page.write_html( [] ) + page.up = ["../output/index.html", "Up"] + page.write_html([]) end end diff --git a/app/page.rb b/app/page.rb index 638f9ad..2d78d05 100644 --- a/app/page.rb +++ b/app/page.rb @@ -1,166 +1,138 @@ -require 'fileutils' +# frozen_string_literal: true -require_relative 'store' +require "fileutils" +require_relative "store" + +# Handles producing a page of output for the Navigator class Page + attr_accessor :back, :forward, :index_file, :output_dir, :special, :title, :up + def initialize(store) @back = nil @forward = nil - @index_file = 'index.html' - @output_dir = 'output' + @index_file = "index.html" + @output_dir = "output" @special = nil @store = store - @title = 'Books' + @title = "Books" @up = nil end - def back=(value) - @back = value - end - - def forward=(value) - @forward = value - end - - def index_file=(value) - @index_file = value - end + def write_books(file_descriptor, book_ids) + book_ids.each do |id| + book = @store.load_book(id) + image = + if book.cover.nil? + "(No cover image)" + else + "" + end + + file_descriptor.puts "
" + file_descriptor.puts " " + + heading = book.heading + description = book.description + if description.nil? + file_descriptor.puts " " + else + pop_inner = "

#{heading}

#{description}

" + file_descriptor.puts " " + end - def navig_link(data) - if (nil == data) - return '' + file_descriptor.puts "
#{image}#{heading}
#{heading}#{pop_inner}
" end - return '' + data[1] + '' - end - - def output_dir=(value) - @output_dir = value - end - - def special=(value) - @special = value end - def title=(value) - @title = value - end + private - def up=(value) - @up = value + def navig_back_up_forward + [navig_link(@back), navig_link(@up), navig_link(@forward)].join(" ") end - def write_books(fd, book_ids) - for id in book_ids - book = @store.load_book(id) - image = nil - if nil != book.cover - #@imageCount += 1 - #(path, mimeType) = book.cover.write_image(@output_dir, 'image' + @imageCount.to_s) - #image = '' - path = book.cover.path - image = '' - else - image = '(No cover image)' - end - - fd.puts '
' - fd.puts ' ' + def navig_link(data) + return "" if data.nil? - heading = book.heading() - description = book.description() - if nil != description - fd.puts ' ' - else - fd.puts ' ' - end - - fd.puts '
' + image + '' + heading + '

' + heading + '

' + description + '

' + heading + '
' - end + "#{data[1]}" end - def write_footer(fd) - fd.puts ' ' + def write_footer(file_descriptor) + file_descriptor.puts "

#{navig_back_up_forward}

" end - def write_header(fd) - fd.puts '

' + @title + '

' + def write_header(file_descriptor) + file_descriptor.puts "

#{@title}

" - fd.puts ' ' + file_descriptor.puts "

#{navig_back_up_forward}

" end def write_html(book_ids) - @imageCount = 0 + FileUtils.mkdir_p(@output_dir) unless Dir.exist?(@output_dir) - if ! Dir.exist?(@output_dir) - FileUtils.mkdir_p(@output_dir) - end + File.open("#{@output_dir}/#{@index_file}", "w") do |file_descriptor| + file_descriptor.puts "" + file_descriptor.puts " " + file_descriptor.puts " " + file_descriptor.puts " #{@title}" - open(@output_dir + '/' + @index_file, 'w') do |fd| - fd.puts '' - fd.puts ' ' - fd.puts ' ' - fd.puts ' ' + @title + '' + write_style_sheet(file_descriptor) - write_style_sheet(fd) + file_descriptor.puts " " + file_descriptor.puts " " - fd.puts ' ' - fd.puts ' ' - - write_header(fd) + write_header(file_descriptor) - write_special(fd) - write_books(fd, book_ids) - - write_footer(fd) + write_special(file_descriptor) + write_books(file_descriptor, book_ids) - fd.puts " " - fd.puts "" + write_footer(file_descriptor) + + file_descriptor.puts " " + file_descriptor.puts "" end end - def write_special(fd) - if (nil != @special) - fd.puts(@special) - end + def write_special(file_descriptor) + file_descriptor.puts(@special) unless @special.nil? end - def write_style_sheet(fd) - style = -< - div { - display: inline-block; - width: 400px; - margin: 10px; - border 3px solid #73ad21; - } - h1.header { - background: #4040a0; - color: #ffffff; - text-align: center; - } - img.cover-thumb { max-height: 200px; max-width: 200px; } - p.navigator { } - span.popup { } - span.popup:hover { text-decoration: none; background: #cfffff; z-index: 6; } - span.popup span.pop-inner { - border-color:black; - border-style:solid; - border-width:1px; - display: none; - margin: 4px 0 0 0px; - padding: 3px 3px 3px 3px; - position: absolute; - } - span.popup:hover span.pop-inner { - background: #ffffaf; - display: block; - margin: 20px 0 0 0px; - z-index:6; - } - -EOS - fd.puts style + def write_style_sheet(file_descriptor) + style = <<~END_STYLE + + END_STYLE + + file_descriptor.puts style end end - diff --git a/app/series.rb b/app/series.rb index 8661704..418c70e 100644 --- a/app/series.rb +++ b/app/series.rb @@ -1,17 +1,15 @@ +# frozen_string_literal: true +# Encapsulates data about a series of books class Series attr_reader :id - attr_accessor :age - attr_accessor :code - attr_accessor :descr - attr_accessor :genre - attr_accessor :grouping + attr_accessor :age, :code, :descr, :genre, :grouping def initialize(id) @age = nil @genre = nil - @grouping = nil + @grouping = nil @code = nil @descr = nil @id = id @@ -30,9 +28,7 @@ class Series end def key - if nil != grouping and nil != code - return grouping.to_s + '_' + code.to_s - end + return "#{grouping}_#{code}" if !grouping.nil? && !code.nil? id.to_s end @@ -41,4 +37,3 @@ class Series inspect end end - diff --git a/app/store.rb b/app/store.rb index 2ee597a..e87ca09 100644 --- a/app/store.rb +++ b/app/store.rb @@ -1,52 +1,59 @@ # frozen_string_literal: true -require 'csv' -require 'fileutils' -require 'inifile' -require 'pg' +require "csv" +require "English" +require "fileutils" +require "inifile" +require "pg" -require_relative 'series' -require_relative 'tconn' +require_relative "series" +require_relative "tconn" +# Handles reads/writes of data stored in the DB class Store def unclassified_csv - @basePath + '/csv/unclassified.csv' + "#{@base_path}/csv/unclassified.csv" end def initialize(config_file) @conn = nil config = IniFile.load(config_file) - if nil == config - puts 'FATAL: Failed to load config file "' + config_file + '". Aborting initialization.' + if config.nil? + puts "FATAL: Failed to load config file #{config_file.inspect}. Aborting initialization." return end - section = config['database'] - @dbhost = section['host'] + section = config["database"] + @dbhost = section["host"] @dbport = 5432 - @dbname = section['name'] - @dbuser = section['user'] - @dbpass = section['pass'] + @dbname = section["name"] + @dbuser = section["user"] + @dbpass = section["pass"] - section = config['filesystem'] - @basePath = section['basePath'] + section = config["filesystem"] + @base_path = section["basePath"] end def connect - @conn = TimedConn.new(PG.connect(@dbhost, @dbport, '', '', @dbname, @dbuser, @dbpass)) - return @conn + @conn = TimedConn.new(PG.connect(@dbhost, @dbport, "", "", @dbname, @dbuser, @dbpass)) + @conn end def disconnect - @conn.close() + @conn.close end def construct_efs_path(efs_id) - id_str = sprintf('%010d', efs_id) - path = sprintf('%s/%s/%s/%s', id_str[0,2], id_str[2,2], id_str[4,2], id_str[6,2]) - name = id_str + '.dat' - return path, name + id_str = format("%010d", efs_id) + + # rubocop:disable Style/FormatStringToken + path = format("%s/%s/%s/%s", id_str[0, 2], id_str[2, 2], id_str[4, 2], id_str[6, 2]) + # rubocop:enable Style/FormatStringToken + + name = "#{id_str}.dat" + + [path, name] end def cross_reference_lists @@ -56,18 +63,16 @@ class Store end def create_schema(skip_class) - create_authors = -< exc - puts 'WARNING: "' + stmt + '" failed: ' + exc.to_s - end + stmts.each do |stmt| + @conn.exec(stmt) + rescue PG::Error => e + puts "WARNING: #{stmt.inspect} failed: #{e}" end end def find_all_authors(author_name) result = [] - sqlSelect = "SELECT id FROM Authors WHERE grouping=$1;" + sql_select = "SELECT id FROM Authors WHERE grouping=$1;" args = [author_name] - @conn.exec_params(sqlSelect, args) do |rs| + @conn.exec_params(sql_select, args) do |rs| rs.each do |row| - result << row['id'] + result << row["id"] end end @@ -231,191 +227,194 @@ EOS end def find_author(author) - sqlSelect = "SELECT id FROM Authors WHERE grouping=$1 AND reading=$2 AND sort=$3;" + sql_select = "SELECT id FROM Authors WHERE grouping=$1 AND reading=$2 AND sort=$3;" args = [author.grouping, author.reading_order, author.sort_order] - @conn.exec_params(sqlSelect, args) do |rs| - if rs.ntuples > 0 - return rs[0]['id'] - end + @conn.exec_params(sql_select, args) do |rs| + return rs[0]["id"] if rs.ntuples.positive? end - return nil + nil end def init_db(skip_class) sql = "SELECT 1 FROM pg_tables WHERE tableowner='quanlib' AND tablename='books'" found = false - @conn.exec(sql).each do |row| + @conn.exec(sql).each do |_row| found = true end - if ! found - create_schema(skip_class) - end + create_schema(skip_class) unless found end def load_author(id) - sqlSelect = "SELECT grouping, reading, sort FROM Authors WHERE id=$1" + sql_select = "SELECT grouping, reading, sort FROM Authors WHERE id=$1" args = [id] - @conn.exec_params(sqlSelect, args) do |rs| - if rs.ntuples != 1 - raise "Expected 1 row for " + id + " but got " + rs.ntuples + ": " + sqlSelect - end + @conn.exec_params(sql_select, args) do |rs| + raise "Expected 1 row for #{id} but got #{rs.ntuples}: #{sql_select}" if rs.ntuples != 1 + row = rs[0] - author = Author.new(row['grouping'], row['reading'], row['sort']) + author = Author.new(row["grouping"], row["reading"], row["sort"]) return author end - return nil + nil end def store_author(author) id = find_author(author) - if nil == id - id = next_id('author_id') - sqlInsert = "INSERT INTO Authors(id, grouping, reading, sort) VALUES ($1, $2, $3, $4);" + if id.nil? + id = next_id("author_id") + sql_insert = "INSERT INTO Authors(id, grouping, reading, sort) VALUES ($1, $2, $3, $4);" args = [id, author.grouping, author.reading_order, author.sort_order] begin - rs = @conn.exec_params(sqlInsert, args) - rescue Exception => e - puts sqlInsert + ": " + args.inspect() + rs = @conn.exec_params(sql_insert, args) + rescue PG::Error => e + puts "#{sql_insert}: #{args.inspect}" puts e.message - puts $@ + puts $ERROR_POSITION ensure - rs.clear if rs + rs&.clear end end - return id + id end def load_book(id) - sql = "SELECT author, classification, cover, description, language, path, series, title, volume FROM Books WHERE id=$1;" + sql = <<~SQL + SELECT author, classification, cover, description, language, path, series, title, volume + FROM Books WHERE id=$1; + SQL book = nil begin @conn.exec_params(sql, [id]) do |rs| - if 1 != rs.ntuples - raise 'Expected one row in Books for id ' + id + ', but found ' + rs.length + '.' - return nil - end + raise "Expected one row in Books for id #{id}, but found #{rs.length}." if rs.ntuples != 1 + row = rs[0] book = Book.new(self) - book.author = load_author(row['author']) - book.classification_id = row['classification'] - book.cover = load_cover(row['cover']) - book.description = row['description'] - book.language = row['language'] - book.path = row['path'] - book.series_id = row['series'] - book.title = row['title'] - book.volume = row['volume'] + book.author = load_author(row["author"]) + book.classification_id = row["classification"] + book.cover = load_cover(row["cover"]) + book.description = row["description"] + book.language = row["language"] + book.path = row["path"] + book.series_id = row["series"] + book.title = row["title"] + book.volume = row["volume"] end - rescue Exception => e - puts sql + ": " + id + rescue PG::Error => e + puts "#{sql}: #{id}" puts e.message - puts $@ + puts $ERROR_POSITION end - return book + book end def store_book(book) - sql = "INSERT INTO Books (id, arrived, author, classification, cover, description, language, path, series, title, volume) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);" + sql = <<~SQL + INSERT INTO + Books (id, arrived, author, classification, cover, description, language, path, series, title, volume) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); + SQL - book_id = next_id('book_id') + book_id = next_id("book_id") author_id = store_author(book.author) efs_id = store_cover(book)&.first - args = [book_id, book.arrived, author_id, book.classification_id, efs_id, book.description, book.language, book.path, book.series_id, book.title, book.volume] + args = [ + book_id, + book.arrived, + author_id, + book.classification_id, + efs_id, + book.description, + book.language, + book.path, + book.series_id, + book.title, + book.volume, + ] begin rs = @conn.exec_params(sql, args) - rescue Exception => e - puts sql + ": " + args.inspect() + rescue PG::Error => e + puts "#{sql}: #{args.inspect}" puts e.message - puts $@ + puts $ERROR_POSITION ensure - rs.clear if rs + rs&.clear end - return book_id + book_id end def find_classification(author_grouping, title_grouping) sql = "SELECT id FROM Classifications WHERE author_grouping = $1 AND title_grouping = $2;" @conn.exec_params(sql, [author_grouping, title_grouping]) do |rs| - if rs.ntuples > 0 - return rs[0]['id'] - end + return rs[0]["id"] if rs.ntuples.positive? end - return nil + nil end def load_classification(id) sql = "SELECT ddc, lcc, author_grouping, author_sort, title_grouping, title " sql += " FROM Classifications WHERE id=$1" @conn.exec_params(sql, [id]) do |rs| - if rs.ntuples > 0 - row = rs[0] - ddc = row['ddc'] - lcc = row['lcc'] - author_grouping = row['author_grouping'] - author = row['author_sort'] - title_grouping = row['title_grouping'] - title = row['title'] - - result = Classification.new(ddc, lcc, author_grouping, author, title_grouping, title) - result.id = id - return result - end + next unless rs.ntuples.positive? + + row = rs[0] + ddc = row["ddc"] + lcc = row["lcc"] + author_grouping = row["author_grouping"] + author = row["author_sort"] + title_grouping = row["title_grouping"] + title = row["title"] + + result = Classification.new(ddc, lcc, author_grouping, author, title_grouping, title) + result.id = id + return result end - return nil + nil end def load_cover(id) - if nil == id - return nil - end + return if id.nil? - mime_type = 'application/octet-stream' + mime_type = "application/octet-stream" sql = "SELECT mimeType FROM Efs WHERE id=$1" @conn.exec_params(sql, [id]) do |rs| - if rs.ntuples != 1 - raise "Expected one row but got " + rs.ntuples + ": " + sql + ": " + id - end - mime_type = rs[0]['mimeType'] + raise "Expected one row but got #{rs.ntuples}: #{sql}: #{id}" if rs.ntuples != 1 + + mime_type = rs[0]["mimeType"] end (efspath, efsname) = construct_efs_path(id) - fullpath = @basePath + '/efs/' + efspath + '/' + efsname + fullpath = "#{@base_path}/efs/#{efspath}/#{efsname}" - return Cover.new(nil, fullpath, mime_type) + Cover.new(nil, fullpath, mime_type) end def store_cover(book) efs_id = nil - cover = book.cover() + cover = book.cover - if nil == cover - return nil - end + return if cover.nil? @conn.exec("SELECT nextval('efs_id')") do |rs| - efs_id = rs[0]['nextval'] + efs_id = rs[0]["nextval"] end - if nil == efs_id - return nil - end + return if efs_id.nil? (efspath, efsname) = construct_efs_path(efs_id) - efspath = @basePath + '/efs/' + efspath + efspath = "#{@base_path}/efs/#{efspath}" FileUtils.mkdir_p(efspath) @@ -424,85 +423,80 @@ EOS sql = "INSERT INTO efs VALUES ($1, $2)" begin rs = @conn.exec_params(sql, [efs_id, mimetype]) - rescue Exception => e - puts sql + ": " + efs_id + ", " + mimetype + rescue PG::Error => e + puts "#{sql}: #{efs_id}, #{mimetype}" puts e.message - puts $@ + puts $ERROR_POSITION ensure - rs.clear if rs + rs&.clear end - return efs_id, mimetype + [efs_id, mimetype] end def exec_id_query(sql, args) ids = [] @conn.exec_params(sql, args) do |rs| rs.each do |row| - ids.push(row['id']) + ids.push(row["id"]) end end - return ids + ids end def exec_update(sql, args) - begin - rs = @conn.exec_params(sql, args) - rescue Exception => e - puts sql + ": " + args.inspect() - puts e.message - puts $@ - ensure - rs.clear if rs - end + rs = @conn.exec_params(sql, args) + rescue PG::Error => e + puts "#{sql}: #{args.inspect}" + puts e.message + puts $ERROR_POSITION + ensure + rs&.clear end def next_id(seq_name) id = nil - @conn.exec("SELECT nextval('" + seq_name + "');") do |rs| - id = rs[0]['nextval'] + @conn.exec("SELECT nextval('#{seq_name}');") do |rs| + id = rs[0]["nextval"] end - return id + id end def get_series(grouping, code) - if nil == code - return nil - end + return if code.nil? sql = "SELECT id FROM Series WHERE grouping=$1 AND code=$2;" args = [grouping, code] - @conn.exec_params(sql, args).each do |row| - return row['id'] - end + result = @conn.exec_params(sql, args) + return result.first["id"] if result.first # TODO: Create a new series object here? - puts 'WARNING: series("' + grouping + '", "' + code + '") not found.' - return nil + puts "WARNING: series(#{grouping.inspect}, #{code.inspect}) not found." + nil end def load_series(id) sql = "SELECT age,genre,grouping,code,descr FROM Series WHERE id=$1;" args = [id] @conn.exec_params(sql, args) do |rs| - if rs.ntuples > 0 - row = rs[0] - series = Series.new(id) - series.age = row['age'] - series.genre = row['genre'] - series.grouping = row['grouping'] - series.code = row['code'] - series.descr = row['descr'] - return series - end + next unless rs.ntuples.positive? + + row = rs[0] + series = Series.new(id) + series.age = row["age"] + series.genre = row["genre"] + series.grouping = row["grouping"] + series.code = row["code"] + series.descr = row["descr"] + return series end - return nil + nil end def populate_classifications_table puts "Populating the Classifications table..." first = true - CSV.foreach(@basePath + '/csv/class.csv') do |row| + CSV.foreach("#{@base_path}/csv/class.csv") do |row| if first # skip the header row first = false @@ -510,7 +504,7 @@ EOS # First, add a row to the Classifications table - id = next_id('classification_id') + id = next_id("classification_id") ddc = row[0] lcc = row[1] author_grouping = row[2] @@ -518,22 +512,23 @@ EOS title_grouping = row[4] title = row[5] - sqlInsert = "INSERT INTO Classifications (id, ddc, lcc, author_grouping, author_sort, title_grouping, title) VALUES ($1, $2, $3, $4, $5, $6, $7);" + sql_insert = <<~SQL + INSERT INTO Classifications (id, ddc, lcc, author_grouping, author_sort, title_grouping, title) + VALUES ($1, $2, $3, $4, $5, $6, $7); + SQL args = [id, ddc, lcc, author_grouping, author_sort, title_grouping, title] - exec_update(sqlInsert, args) + exec_update(sql_insert, args) # Second, link up with the appropriate FAST table entries fast = [] input = row[6] - if input.length > 0 - fast = input.split(';') - end + fast = input.split(";") unless input.empty? fast.each do |fast_id| - sqlInsert = "INSERT INTO FAST_Classifications (fast, classification) VALUES ($1, $2);" + sql_insert = "INSERT INTO FAST_Classifications (fast, classification) VALUES ($1, $2);" args = [fast_id, id] - exec_update(sqlInsert, args) + exec_update(sql_insert, args) end end end @@ -542,14 +537,14 @@ EOS def populate_fast_table puts "Populating the FAST table..." first = true - CSV.foreach(@basePath + '/csv/fast.csv') do |row| + CSV.foreach("#{@base_path}/csv/fast.csv") do |row| if first first = false # skip the header row else id = row[0] descr = row[1] - sqlInsert = "INSERT INTO FAST (id, descr) VALUES ($1, $2);" - exec_update(sqlInsert, [id, descr]) + sql_insert = "INSERT INTO FAST (id, descr) VALUES ($1, $2);" + exec_update(sql_insert, [id, descr]) end end end @@ -557,98 +552,93 @@ EOS def populate_lists_table puts "Populating the Lists table..." - CSV.foreach(@basePath + "/csv/lists.csv", headers: true) do |row| - author_ids = find_all_authors(row['author']) + CSV.foreach("#{@base_path}/csv/lists.csv", headers: true) do |row| + author_ids = find_all_authors(row["author"]) if author_ids.empty? - specification = [row['age'], row['category'], row['code'], row['year'], row['author'], row['title']] - .map { |x| x.inspect } - .join(', ') + specification = + [row["age"], row["category"], row["code"], row["year"], row["author"], row["title"]] + .map(&:inspect) + .join(", ") puts "WARNING: For list entry (#{specification}), no such author was found." next end - sqlInsert = %Q( + sql_insert = <<~SQL INSERT INTO Lists (id, age, category, code, year, author, title) VALUES ($1, $2, $3, $4, $5, $6, $7); - ) + SQL author_ids.each do |author_id| - list_id = next_id('list_id') - args = [list_id, row['age'], row['category'], row['code'], row['year'], author_id, row['title']] - exec_update(sqlInsert, args) + list_id = next_id("list_id") + args = [list_id, row["age"], row["category"], row["code"], row["year"], author_id, row["title"]] + exec_update(sql_insert, args) - update_lists_books_table(list_id, author_id, row['title']) + update_lists_books_table(list_id, author_id, row["title"]) end end end # Scan for books that match this Lists entry, and add any matches to the Lists_Books associative table def update_lists_books_table(list_id, author_id, title) - title_pattern = Book.grouping_for_title(title).gsub('_', '%') - sqlSelect = "SELECT id FROM Books WHERE author = $1 AND title LIKE $2;" + title_pattern = Book.grouping_for_title(title).gsub("_", "%") + sql_select = "SELECT id FROM Books WHERE author = $1 AND title LIKE $2;" args = [author_id, title_pattern] - @conn.exec_params(sqlSelect, args) do |rs| + @conn.exec_params(sql_select, args) do |rs| rs.each do |row| - sqlInsert = "INSERT INTO Lists_Books (list, book) VALUES ($1, $2)" - args = [list_id, row['id']] - exec_update(sqlInsert, args) + sql_insert = "INSERT INTO Lists_Books (list, book) VALUES ($1, $2)" + args = [list_id, row["id"]] + exec_update(sql_insert, args) end end end def populate_series_table puts "Populating the Series table..." - CSV.foreach(@basePath + '/csv/series.csv') do |row| - id = next_id('series_id') - sqlInsert = "INSERT INTO Series (id, age, genre, grouping, code, descr) VALUES ($1, $2, $3, $4, $5, $6);" + CSV.foreach("#{@base_path}/csv/series.csv") do |row| + id = next_id("series_id") + sql_insert = "INSERT INTO Series (id, age, genre, grouping, code, descr) VALUES ($1, $2, $3, $4, $5, $6);" args = [id] + row - exec_update(sqlInsert, args) + exec_update(sql_insert, args) end end def query_books_by_author(pattern) - sql = -< 0) - result.concat(sub) - end - elsif (! File.directory?(fullName)) - result.push(fullName) + children.each do |child| + full_name = "#{path.chomp("/")}/#{child}" + if should_descend_into_directory?(full_name) + sub = walk(full_name) + result.concat(sub) if !sub.nil? && !sub.empty? + elsif !File.directory?(full_name) + result.push(full_name) end end - return result + result end + private + def num_threads # TOOD: make this (auto?) configurable 12 end + + def should_descend_into_directory?(path) + child = File.basename(path) + File.directory?(path) && child != "." && child != ".." && !File.symlink?(path) + end end diff --git a/test/book_test.rb b/test/book_test.rb index bb476c7..034ae04 100644 --- a/test/book_test.rb +++ b/test/book_test.rb @@ -7,13 +7,13 @@ require "store_mock" class BookTest < Minitest::Test def test_that_it_can_handle_epub_and_pdf_files - %w(epub pdf).each do |extension| + %w[epub pdf].each do |extension| assert_equal true, Book.can_handle?("sample.#{extension}") end end def test_that_it_cannot_handle_mobi_html_txt_doc_zip_rtf_nor_rar - %w(doc html mobi rar rtf txt zip).each do |extension| + %w[doc html mobi rar rtf txt zip].each do |extension| assert_equal false, Book.can_handle?("sample.#{extension}") end end @@ -33,7 +33,7 @@ class BookTest < Minitest::Test def test_load_from_file store = StoreMock.new - store.expects(:get_series).returns(mock_series_LW) + store.expects(:get_series).returns(mock_series_lw) store.connect book = Book.new(store) @@ -45,16 +45,19 @@ class BookTest < Minitest::Test assert_equal "Louisa May Alcott", author.reading_order assert_equal "Alcott, Louisa May", author.sort_order - assert_equal "This story follows the lives of the four March sisters&emdash;Meg, Jo, Beth, and Amy&emdash;and details their coming of age.", book.description + expected_descr = "This story follows the lives of the four March sisters&emdash;Meg, Jo, Beth, and Amy&emdash;" \ + "and details their coming of age." + + assert_equal expected_descr, book.description assert_equal "en", book.language assert_equal "Little Women: Or, Meg, Jo, Beth and Amy", book.title - assert_equal mock_series_LW.to_s, book.series_id.to_s + assert_equal mock_series_lw.to_s, book.series_id.to_s assert_equal 1, book.volume.to_i end def test_heading store = StoreMock.new - store.expects(:get_series).returns(mock_series_LW) + store.expects(:get_series).returns(mock_series_lw) store.connect book = Book.new(store) @@ -68,7 +71,7 @@ class BookTest < Minitest::Test private - def mock_series_LW + def mock_series_lw id = 1 series = Series.new(id) series.age = "ya" diff --git a/test/classification_test.rb b/test/classification_test.rb index fd11643..570393c 100644 --- a/test/classification_test.rb +++ b/test/classification_test.rb @@ -11,10 +11,12 @@ class ClassificationTest < Minitest::Test author_sort = "Franklin, Benjamin" title_grouping = "Autobiography_of_Benjamin_Franklin" title = "Autobiography of Benjamin Franklin" - + classification = Classification.new(ddc, lcc, author_grouping, author_sort, title_grouping, title) - expected = '(Classification: Dewey="973.3/092 B", LCC="E302.6.F7", author_grouping="BenjaminFranklin", author="Franklin, Benjamin", title_grouping="Autobiography_of_Benjamin_Franklin", title="Autobiography of Benjamin Franklin")' + expected = '(Classification: Dewey="973.3/092 B", LCC="E302.6.F7", author_grouping="BenjaminFranklin", ' \ + 'author="Franklin, Benjamin", title_grouping="Autobiography_of_Benjamin_Franklin", title="Autobiography ' \ + 'of Benjamin Franklin")' actual = classification.inspect assert_equal expected, actual diff --git a/test/conn_mock.rb b/test/conn_mock.rb index c090404..ff3b4b3 100644 --- a/test/conn_mock.rb +++ b/test/conn_mock.rb @@ -12,13 +12,13 @@ class ConnMock @mock_connected = false end - def exec(*args, &block) + def exec(*args, &_block) @stmts << args - return nil + nil end - def exec_params(*args, &block) + def exec_params(*args, &_block) @stmts << args - return nil + nil end end diff --git a/test/store_mock.rb b/test/store_mock.rb index 897e79a..5d16cff 100644 --- a/test/store_mock.rb +++ b/test/store_mock.rb @@ -4,16 +4,18 @@ require "conn_mock" require "store" class StoreMock < Store + # rubocop:disable Lint/MissingSuper def initialize @dbhost = "host" @dbport = 5432 @dbname = "dbname" @dbuser = "dbuser" @dbpass = "dbpass" - @basePath = "base_path" + @base_path = "base_path" @conn = nil end + # rubocop:enable Lint/MissingSuper def connect raise "Mock error: .connect called when already connected" if @conn diff --git a/test/store_test.rb b/test/store_test.rb index 46676b8..9fd0494 100644 --- a/test/store_test.rb +++ b/test/store_test.rb @@ -6,16 +6,18 @@ require "store" class StoreTest < Minitest::Test def test_construct_efs_path + # rubocop:disable Layout/ExtraSpacing,Layout/SpaceInsideArrayLiteralBrackets data = [ - [ 1234, '00/00/00/12', '0000001234.dat'], - [ 1, '00/00/00/00', '0000000001.dat'], - [1234567890, '12/34/56/78', '1234567890.dat'], - [ 7778123, '00/07/77/81', '0007778123.dat'], - [ 0x1b, '00/00/00/00', '0000000027.dat'] + [ 1234, "00/00/00/12", "0000001234.dat"], + [ 1, "00/00/00/00", "0000000001.dat"], + [1_234_567_890, "12/34/56/78", "1234567890.dat"], + [ 7_778_123, "00/07/77/81", "0007778123.dat"], + [ 0x1b, "00/00/00/00", "0000000027.dat"], ] + # rubocop:enable Layout/ExtraSpacing,Layout/SpaceInsideArrayLiteralBrackets - IniFile.stubs(:load).returns({"database" => {}, "filesystem" => {}}) - store = Store.new('quanlib.ini') + IniFile.stubs(:load).returns({ "database" => {}, "filesystem" => {} }) + store = Store.new("quanlib.ini") data.each do |input, expected_path, expected_name| (actual_path, actual_name) = store.construct_efs_path(input) diff --git a/test/walk_dir_test.rb b/test/walk_dir_test.rb index e47ef47..fc068d1 100644 --- a/test/walk_dir_test.rb +++ b/test/walk_dir_test.rb @@ -5,34 +5,34 @@ require "test_helper" require "walk_dir" class WalkDirTest < Minitest::Test - def test_is_duplicate - yes = %w( + def test_duplicate + yes = %w[ Little_Women_bis.pdf The_Autobiography_of_Benjamin_Franklin_ter.epub Romeo_and_Juliet_quater.pdf As_You_Like_It_quinquies.epub - ) + ] - no = %w( + no = %w[ Little_Women.pdf The_Autobiography_of_Benjamin_Franklin.epub Romeo_and_Juliet.pdf As_You_Like_It.epub - ) + ] walk_dir = create_walk_dir yes.each do |filename| - assert_equal true, walk_dir.is_duplicate?(filename) + assert_equal true, walk_dir.duplicate?(filename) end no.each do |filename| - assert_equal false, walk_dir.is_duplicate?(filename) + assert_equal false, walk_dir.duplicate?(filename) end end def test_remove_duplicates - input = %w( + input = %w[ 00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.epub 00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.jpeg 00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.pdf @@ -41,14 +41,14 @@ class WalkDirTest < Minitest::Test FranklinDixon/HB001_The_Tower_Treasure.jpeg FranklinDixon/HB001_The_Tower_Treasure.pdf FranklinDixon/HB002_The_House_on_the_Cliff.epub - ) + ] - expected = %w( + expected = %w[ 00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.epub LouisaAlcott/Little_Women.epub FranklinDixon/HB001_The_Tower_Treasure.epub FranklinDixon/HB002_The_House_on_the_Cliff.epub - ) + ] walk_dir = create_walk_dir actual = walk_dir.remove_duplicates(input) @@ -57,60 +57,70 @@ class WalkDirTest < Minitest::Test end def test_walk - root_entries = %w( + root_entries = set_sample_dir_expectations + + walk_dir = create_walk_dir(".", root_entries) + files = walk_dir.instance_variable_get(:@files) + + expected = %w[ + ./00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.epub + ./LouisaAlcott/LW01_Little_Women.epub + ./FranklinDixon/HB001_The_Tower_Treasure.epub + ./FranklinDixon/HB002_The_House_on_the_Cliff.epub + ] + + assert_equal expected, files + end + + private + + def create_walk_dir(root_path = "/does/not/exist", root_entries = []) + config_file = "quanlib.ini" + Dir.expects(:entries).with(root_path).returns(root_entries) + + WalkDir.new(config_file, root_path) + end + + def set_sample_dir_expectations + root_entries = %w[ 00_nonFiction LouisaAlcott FranklinDixon - ) + ] - Dir.expects(:entries).with("./00_nonFiction").returns(%w(BenjaminFranklin)) - Dir.expects(:entries).with("./00_nonFiction/BenjaminFranklin").returns(%w( - The_Autobiography_of_Benjamin_Franklin.epub - )) + Dir.expects(:entries).with("./00_nonFiction").returns(%w[BenjaminFranklin]) + Dir.expects(:entries).with("./00_nonFiction/BenjaminFranklin").returns( + %w[ + The_Autobiography_of_Benjamin_Franklin.epub + ] + ) Dir.expects(:entries).with("./LouisaAlcott").returns(["LW01_Little_Women.epub"]) - Dir.expects(:entries).with("./FranklinDixon").returns(%w( - HB001_The_Tower_Treasure.epub - HB002_The_House_on_the_Cliff.epub - )) + Dir.expects(:entries).with("./FranklinDixon").returns( + %w[ + HB001_The_Tower_Treasure.epub + HB002_The_House_on_the_Cliff.epub + ] + ) - %w( + %w[ ./00_nonFiction ./00_nonFiction/BenjaminFranklin ./LouisaAlcott ./FranklinDixon - ).each do |path| + ].each do |path| File.expects(:directory?).with(path).returns(true).at_least_once File.expects(:symlink?).with(path).returns(false).at_least_once end - %w( + %w[ ./00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.epub ./LouisaAlcott/LW01_Little_Women.epub ./FranklinDixon/HB001_The_Tower_Treasure.epub ./FranklinDixon/HB002_The_House_on_the_Cliff.epub - ).each do |path| + ].each do |path| File.expects(:directory?).with(path).returns(false).at_least_once end - walk_dir = create_walk_dir(".", root_entries) - files = walk_dir.instance_variable_get(:@files) - - expected = %w( - ./00_nonFiction/BenjaminFranklin/The_Autobiography_of_Benjamin_Franklin.epub - ./LouisaAlcott/LW01_Little_Women.epub - ./FranklinDixon/HB001_The_Tower_Treasure.epub - ./FranklinDixon/HB002_The_House_on_the_Cliff.epub - ) - - assert_equal expected, files - end - - private - - def create_walk_dir(root_path = "/does/not/exist", root_entries = []) - config_file = "quanlib.ini" - Dir.expects(:entries).with(root_path).returns(root_entries) - - WalkDir.new(config_file, root_path) + root_entries end end -- 2.39.2