epub_tools 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.document +2 -0
  3. data/.github/workflows/ci.yml +9 -8
  4. data/.gitignore +4 -0
  5. data/.rubocop.yml +41 -0
  6. data/Gemfile +17 -8
  7. data/Gemfile.lock +51 -0
  8. data/LICENSE +21 -0
  9. data/README.md +21 -3
  10. data/bin/epub-tools +3 -109
  11. data/epub_tools.gemspec +6 -8
  12. data/lib/epub_tools/add_chapters.rb +124 -0
  13. data/lib/epub_tools/cli/command_registry.rb +47 -0
  14. data/lib/epub_tools/cli/option_builder.rb +164 -0
  15. data/lib/epub_tools/cli/runner.rb +164 -0
  16. data/lib/epub_tools/cli.rb +45 -0
  17. data/lib/epub_tools/compile_book.rb +77 -34
  18. data/lib/epub_tools/epub_initializer.rb +48 -26
  19. data/lib/epub_tools/loggable.rb +11 -0
  20. data/lib/epub_tools/pack_ebook.rb +20 -13
  21. data/lib/epub_tools/split_chapters.rb +40 -21
  22. data/lib/epub_tools/style_finder.rb +58 -0
  23. data/lib/epub_tools/unpack_ebook.rb +23 -16
  24. data/lib/epub_tools/version.rb +2 -1
  25. data/lib/epub_tools/xhtml_cleaner.rb +28 -8
  26. data/lib/epub_tools/xhtml_extractor.rb +23 -10
  27. data/lib/epub_tools.rb +4 -2
  28. data/test/{add_chapters_to_epub_test.rb → add_chapters_test.rb} +14 -7
  29. data/test/cli/command_registry_test.rb +66 -0
  30. data/test/cli/option_builder_test.rb +173 -0
  31. data/test/cli/runner_test.rb +91 -0
  32. data/test/cli_commands_test.rb +100 -0
  33. data/test/cli_test.rb +4 -0
  34. data/test/cli_version_test.rb +5 -3
  35. data/test/compile_book_test.rb +11 -2
  36. data/test/epub_initializer_test.rb +51 -31
  37. data/test/pack_ebook_test.rb +14 -8
  38. data/test/split_chapters_test.rb +22 -1
  39. data/test/{text_style_class_finder_test.rb → style_finder_test.rb} +7 -6
  40. data/test/test_helper.rb +4 -5
  41. data/test/unpack_ebook_test.rb +21 -5
  42. data/test/xhtml_cleaner_test.rb +13 -7
  43. data/test/xhtml_extractor_test.rb +17 -1
  44. metadata +24 -39
  45. data/lib/epub_tools/add_chapters_to_epub.rb +0 -87
  46. data/lib/epub_tools/cli_helper.rb +0 -31
  47. data/lib/epub_tools/text_style_class_finder.rb +0 -47
@@ -0,0 +1,11 @@
1
+ module EpubTools
2
+ # Provides logging capability to classes that include it
3
+ module Loggable
4
+ # Logs a message if verbose mode is enabled
5
+ # @param message [String] The message to log
6
+ # @return [nil]
7
+ def log(message)
8
+ puts message if @verbose
9
+ end
10
+ end
11
+ end
@@ -1,24 +1,30 @@
1
1
  require 'zip'
2
2
  require 'fileutils'
3
3
  require 'pathname'
4
+ require_relative 'loggable'
4
5
 
5
6
  module EpubTools
6
7
  # Packages an EPUB directory into a .epub file
7
8
  class PackEbook
8
- # input_dir: path to the EPUB directory (containing mimetype, META-INF, OEBPS)
9
- # output_file: path to resulting .epub file; if nil, defaults to <input_dir>.epub
10
- def initialize(input_dir, output_file = nil, verbose: false)
11
- @input_dir = File.expand_path(input_dir)
9
+ include Loggable
10
+ # Initializes the class
11
+ # @param options [Hash] Configuration options
12
+ # @option options [String] :input_dir Path to the EPUB directory (containing mimetype, META-INF, OEBPS) (required)
13
+ # @option options [String] :output_file Path to resulting .epub file (default: <input_dir>.epub)
14
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
15
+ def initialize(options = {})
16
+ @input_dir = File.expand_path(options.fetch(:input_dir))
12
17
  default_name = "#{File.basename(@input_dir)}.epub"
18
+ output_file = options[:output_file]
13
19
  @output_file = if output_file.nil? || output_file.empty?
14
20
  default_name
15
21
  else
16
22
  output_file
17
23
  end
18
- @verbose = verbose
24
+ @verbose = options[:verbose] || false
19
25
  end
20
26
 
21
- # Run the packaging process
27
+ # Runs the packaging process and returns the resulting file path
22
28
  def run
23
29
  validate_input!
24
30
  Dir.chdir(@input_dir) do
@@ -33,23 +39,24 @@ module EpubTools
33
39
  Dir.glob('**/*', File::FNM_DOTMATCH).sort.each do |entry|
34
40
  next if ['.', '..', 'mimetype'].include?(entry)
35
41
  next if File.directory?(entry)
42
+
36
43
  zip.add(entry, entry)
37
44
  end
38
45
  end
39
46
  end
40
- puts "EPUB created: #{@output_file}" if @verbose
47
+ log "EPUB created: #{@output_file}"
48
+ @output_file
41
49
  end
42
50
 
43
51
  private
44
52
 
45
53
  def validate_input!
46
- unless Dir.exist?(@input_dir)
47
- raise ArgumentError, "Directory '#{@input_dir}' does not exist."
48
- end
54
+ raise ArgumentError, "Directory '#{@input_dir}' does not exist." unless Dir.exist?(@input_dir)
55
+
49
56
  mimetype = File.join(@input_dir, 'mimetype')
50
- unless File.file?(mimetype)
51
- raise ArgumentError, "Error: 'mimetype' file missing in #{@input_dir}"
52
- end
57
+ return if File.file?(mimetype)
58
+
59
+ raise ArgumentError, "Error: 'mimetype' file missing in #{@input_dir}"
53
60
  end
54
61
 
55
62
  def add_mimetype(zip)
@@ -1,33 +1,48 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'nokogiri'
3
3
  require 'yaml'
4
- require_relative 'text_style_class_finder'
4
+ require 'fileutils'
5
+ require_relative 'loggable'
6
+ require_relative 'style_finder'
5
7
  require_relative 'xhtml_cleaner'
6
8
 
7
9
  module EpubTools
10
+ # Takes a Google Docs generated, already extracted from their EPUB, XHTML files with multiple
11
+ # chapters and it:
12
+ # - Extracts classes using {StyleFinder}[rdoc-ref:EpubTools::StyleFinder]
13
+ # - Looks for tags that say something like Chapter XX or Prologue and splits the text there
14
+ # - Creates new chapter_XX.xhtml files that are cleaned using
15
+ # {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
16
+ # - Saves those files to +output_dir+
8
17
  class SplitChapters
9
- # input_file: path to the source XHTML
10
- # book_title: title to use in HTML <title> tags
11
- # output_dir: where to write chapter files
12
- # output_prefix: filename prefix (e.g. "chapter")
13
- def initialize(input_file, book_title, output_dir = './chapters', output_prefix = 'chapter', verbose = false)
14
- @input_file = input_file
15
- @book_title = book_title
16
- @output_dir = output_dir
17
- @output_prefix = output_prefix
18
- @verbose = verbose
18
+ include Loggable
19
+ # Initializes the class
20
+ # @param options [Hash] Configuration options
21
+ # @option options [String] :input_file Path to the source XHTML (required)
22
+ # @option options [String] :book_title Title to use in HTML <title> tags (required)
23
+ # @option options [String] :output_dir Where to write chapter files (default: './chapters')
24
+ # @option options [String] :output_prefix Filename prefix for chapter files (default: 'chapter')
25
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
26
+ def initialize(options = {})
27
+ @input_file = options.fetch(:input_file)
28
+ @book_title = options.fetch(:book_title)
29
+ @output_dir = options[:output_dir] || './chapters'
30
+ @output_prefix = options[:output_prefix] || 'chapter'
31
+ @verbose = options[:verbose] || false
19
32
  end
20
33
 
34
+ # Runs the splitter
35
+ # @return [Array<String>] List of generated chapter file paths
21
36
  def run
22
37
  # Prepare output dir
23
- Dir.mkdir(@output_dir) unless Dir.exist?(@output_dir)
38
+ FileUtils.mkdir_p(@output_dir)
24
39
 
25
40
  # Read the doc
26
- raw_content = read_and_strip_problematic_hr
41
+ raw_content = read_and_strip_problematic_tags
27
42
  doc = Nokogiri::HTML(raw_content)
28
43
 
29
44
  # Find Style Classes
30
- TextStyleClassFinder.new(@input_file, verbose: @verbose).call
45
+ StyleFinder.new({ file_path: @input_file, verbose: @verbose }).run
31
46
 
32
47
  chapters = extract_chapters(doc)
33
48
  write_chapter_files(chapters)
@@ -35,8 +50,8 @@ module EpubTools
35
50
 
36
51
  private
37
52
 
38
- def read_and_strip_problematic_hr
39
- File.read(@input_file).gsub(/<hr\b[^>]*\/?>/i, '').gsub(/<br\b[^>]*\/?>/i, '')
53
+ def read_and_strip_problematic_tags
54
+ File.read(@input_file).gsub(%r{<hr\b[^>]*/?>}i, '').gsub(%r{<br\b[^>]*/?>}i, '')
40
55
  end
41
56
 
42
57
  def extract_chapters(doc)
@@ -65,9 +80,12 @@ module EpubTools
65
80
  end
66
81
 
67
82
  def write_chapter_files(chapters)
83
+ chapter_files = []
68
84
  chapters.each do |number, content|
69
- write_chapter_file(number, content)
85
+ filename = write_chapter_file(number, content)
86
+ chapter_files << filename
70
87
  end
88
+ chapter_files
71
89
  end
72
90
 
73
91
  def write_chapter_file(label, content)
@@ -86,20 +104,21 @@ module EpubTools
86
104
  </body>
87
105
  </html>
88
106
  HTML
89
- XHTMLCleaner.new(filename).call
90
- puts "Extracted: #{filename}" if @verbose
107
+ XHTMLCleaner.new({ filename: filename }).run
108
+ log("Extracted: #{filename}")
109
+ filename
91
110
  end
92
111
 
93
112
  def display_label(label)
94
- label > 0 ? "Chapter #{label}" : "Prologue"
113
+ label.positive? ? "Chapter #{label}" : 'Prologue'
95
114
  end
96
115
 
97
116
  # Detect a bolded Prologue marker
98
117
  def prologue_marker?(node)
99
118
  return false unless %w[h3 h4].include?(node.name)
100
119
  return false unless node.text.strip =~ /\APrologue\z/i
120
+
101
121
  true
102
122
  end
103
-
104
123
  end
105
124
  end
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ require 'nokogiri'
3
+ require 'yaml'
4
+ require_relative 'loggable'
5
+
6
+ module EpubTools
7
+ # Finds css classes for bold and italic texts in Google Docs-generated EPUBs. Used by
8
+ # {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner] and
9
+ # {SplitChapters}[rdoc-ref:EpubTools::SplitChapters].
10
+ class StyleFinder
11
+ include Loggable
12
+ # Initializes the class
13
+ # @param options [Hash] Configuration options
14
+ # @option options [String] :file_path XHTML file to be analyzed (required)
15
+ # @option options [String] :output_path Path to write the YAML file (default: 'text_style_classes.yaml')
16
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
17
+ def initialize(options = {})
18
+ @file_path = options.fetch(:file_path)
19
+ @output_path = options[:output_path] || 'text_style_classes.yaml'
20
+ @verbose = options[:verbose] || false
21
+ raise ArgumentError, "File does not exist: #{@file_path}" unless File.exist?(@file_path)
22
+ end
23
+
24
+ # Runs the finder
25
+ # @return [Hash] Data containing the extracted style classes (italics and bolds)
26
+ def run
27
+ doc = Nokogiri::HTML(File.read(@file_path))
28
+ style_blocks = doc.xpath('//style').map(&:text).join("\n")
29
+
30
+ italics = extract_classes(style_blocks, /font-style\s*:\s*italic/)
31
+ bolds = extract_classes(style_blocks, /font-weight\s*:\s*700/)
32
+
33
+ print_summary(italics, bolds) if @verbose
34
+
35
+ data = {
36
+ 'italics' => italics,
37
+ 'bolds' => bolds
38
+ }
39
+ File.write(@output_path, data.to_yaml)
40
+ data
41
+ end
42
+
43
+ private
44
+
45
+ def extract_classes(style_text, pattern)
46
+ regex = /\.([\w-]+)\s*{[^}]*#{pattern.source}[^}]*}/i
47
+ style_text.scan(regex).flatten.uniq
48
+ end
49
+
50
+ def print_summary(italics, bolds)
51
+ log "Classes with font-style: italic: #{italics.join(', ')}" unless italics.empty?
52
+
53
+ return if bolds.empty?
54
+
55
+ log "Classes with font-weight: 700: #{bolds.join(', ')}"
56
+ end
57
+ end
58
+ end
@@ -1,23 +1,25 @@
1
1
  require 'zip'
2
2
  require 'fileutils'
3
+ require_relative 'loggable'
3
4
 
4
5
  module EpubTools
5
6
  # Unpacks an EPUB (.epub file) into a directory
6
7
  class UnpackEbook
7
- # epub_file: path to the .epub file
8
- # output_dir: directory to extract into; defaults to basename of epub_file without .epub
9
- def initialize(epub_file, output_dir = nil, verbose: false)
10
- @epub_file = File.expand_path(epub_file)
11
- default_dir = [File.dirname(@epub_file), File.basename(@epub_file, '.epub')].join("/")
12
- @output_dir = if output_dir.nil? || output_dir.empty?
13
- default_dir
14
- else
15
- output_dir
16
- end
17
- @verbose = verbose
8
+ include Loggable
9
+ # Initializes the class
10
+ # @param options [Hash] Configuration options
11
+ # @option options [String] :epub_file Path to the .epub file to unpack (required)
12
+ # @option options [String] :output_dir Directory to extract into (default: basename of epub_file without .epub)
13
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
14
+ def initialize(options = {})
15
+ @epub_file = File.expand_path(options.fetch(:epub_file))
16
+ output_dir = options[:output_dir]
17
+ @output_dir = output_dir.nil? || output_dir.empty? ? default_dir : output_dir
18
+ @verbose = options[:verbose] || false
18
19
  end
19
20
 
20
- # Extracts all entries from the EPUB into the output directory
21
+ # Extracts all entries from the EPUB into the output directory. Returns the output
22
+ # directory.
21
23
  def run
22
24
  validate!
23
25
  FileUtils.mkdir_p(@output_dir)
@@ -32,15 +34,20 @@ module EpubTools
32
34
  end
33
35
  end
34
36
  end
35
- puts "Unpacked #{File.basename(@epub_file)} to #{@output_dir}" if @verbose
37
+ log "Unpacked #{File.basename(@epub_file)} to #{@output_dir}"
38
+ @output_dir
36
39
  end
37
40
 
38
41
  private
39
42
 
43
+ def default_dir
44
+ [File.dirname(@epub_file), File.basename(@epub_file, '.epub')].join('/')
45
+ end
46
+
40
47
  def validate!
41
- unless File.file?(@epub_file)
42
- raise ArgumentError, "EPUB file '#{@epub_file}' does not exist"
43
- end
48
+ return if File.file?(@epub_file)
49
+
50
+ raise ArgumentError, "EPUB file '#{@epub_file}' does not exist"
44
51
  end
45
52
  end
46
53
  end
@@ -1,3 +1,4 @@
1
1
  module EpubTools
2
- VERSION = '0.3.0'
2
+ # Ruby Gem version number
3
+ VERSION = '0.4.0'.freeze
3
4
  end
@@ -4,13 +4,32 @@ require 'nokogiri'
4
4
  require 'yaml'
5
5
 
6
6
  module EpubTools
7
+ # Cleans Google Docs XHTMLs
8
+
9
+ # Google Docs makes a mess out of EPUBs and creates html without proper tag names and just uses
10
+ # classes for _everything_. This class does the following to clean invalid xhtml:
11
+ #
12
+ # - Removes any <tt><br /></tt> or <tt><hr /></tt> tags.
13
+ # - Removes empty <tt><p></tt> tags.
14
+ # - Using the +class_config+, it removes <tt><span></tt> tags that are used for bold or italics and
15
+ # replaces them with <tt><b></tt> or <tt><i></tt> tags.
16
+ # - Unwraps any <tt><span></tt> tags that have no classes assigned.
17
+ # - Outputs everything to a cleanly formatted +.xhtml+
7
18
  class XHTMLCleaner
8
- def initialize(filename, class_config = 'text_style_classes.yaml')
9
- @filename = filename
19
+ # Initializes the class
20
+ # @param options [Hash] Configuration options
21
+ # @option options [String] :filename The path to the xhtml to clean (required)
22
+ # @option options [String] :class_config Path to a YAML file containing the bold and italic classes to check
23
+ # (default: 'text_style_classes.yaml')
24
+ def initialize(options = {})
25
+ @filename = options.fetch(:filename)
26
+ class_config = options[:class_config] || 'text_style_classes.yaml'
10
27
  @classes = YAML.load_file(class_config).transform_keys(&:to_sym)
11
28
  end
12
29
 
13
- def call
30
+ # Runs the cleaner
31
+ # @return [String] Path to the cleaned file
32
+ def run
14
33
  raw_content = read_and_strip_problematic_hr
15
34
  doc = parse_xml(raw_content)
16
35
  remove_empty_paragraphs(doc)
@@ -18,24 +37,25 @@ module EpubTools
18
37
  replace_italic_spans(doc)
19
38
  unwrap_remaining_spans(doc)
20
39
  write_pretty_output(doc)
40
+ @filename
21
41
  end
22
42
 
23
43
  private
24
44
 
25
45
  def read_and_strip_problematic_hr
26
- File.read(@filename).gsub(/<hr\b[^>]*\/?>/i, '').gsub(/<br\b[^>]*\/?>/i, '')
46
+ File.read(@filename).gsub(%r{<hr\b[^>]*/?>}i, '').gsub(%r{<br\b[^>]*/?>}i, '')
27
47
  end
28
48
 
29
49
  def parse_xml(content)
30
50
  Nokogiri::XML(content) { |config| config.default_xml.noblanks }
31
- rescue => e
51
+ rescue StandardError => e
32
52
  abort "Error parsing XML: #{e.message}"
33
53
  end
34
54
 
35
55
  def remove_empty_paragraphs(doc)
36
56
  doc.css('p').each do |p|
37
57
  content = p.inner_html.strip
38
- if content.empty? || content =~ /\A(<span[^>]*>\s*<\/span>\s*)+\z/
58
+ if content.empty? || content =~ %r{\A(<span[^>]*>\s*</span>\s*)+\z}
39
59
  p.remove
40
60
  else
41
61
  p.remove_attribute('class')
@@ -54,14 +74,14 @@ module EpubTools
54
74
  def replace_italic_spans(doc)
55
75
  @classes[:italics].each do |class_name|
56
76
  doc.css("span.#{class_name}").each do |node|
57
- node.name = "i"
77
+ node.name = 'i'
58
78
  node.remove_attribute('class')
59
79
  end
60
80
  end
61
81
  end
62
82
 
63
83
  def unwrap_remaining_spans(doc)
64
- doc.css("span").each do |span|
84
+ doc.css('span').each do |span|
65
85
  span.add_next_sibling(span.dup.content)
66
86
  span.remove
67
87
  end
@@ -1,20 +1,32 @@
1
1
  require 'zip'
2
2
  require 'fileutils'
3
+ require_relative 'loggable'
3
4
 
4
5
  module EpubTools
5
- # Extracts .xhtml files from EPUB archives, excluding nav.xhtml
6
+ # Extracts text .xhtml files from EPUB archives, excluding nav.xhtml
6
7
  class XHTMLExtractor
7
- def initialize(source_dir:, target_dir:, verbose: false)
8
- @source_dir = File.expand_path(source_dir)
9
- @target_dir = File.expand_path(target_dir)
10
- @verbose = verbose
8
+ include Loggable
9
+ # Initializes the class
10
+ # @param options [Hash] Configuration options
11
+ # @option options [String] :source_dir Directory containing source .epub files (required)
12
+ # @option options [String] :target_dir Directory where .xhtml files will be extracted (required)
13
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
14
+ def initialize(options = {})
15
+ @source_dir = File.expand_path(options.fetch(:source_dir))
16
+ @target_dir = File.expand_path(options.fetch(:target_dir))
17
+ @verbose = options[:verbose] || false
11
18
  FileUtils.mkdir_p(@target_dir)
12
19
  end
13
20
 
14
- def extract_all
21
+ # Runs the extraction process
22
+ # @return [Array<String>] Paths to all extracted XHTML files
23
+ def run
24
+ all_extracted_files = []
15
25
  epub_files.each do |epub_path|
16
- extract_xhtmls_from(epub_path)
26
+ extracted = extract_xhtmls_from(epub_path)
27
+ all_extracted_files.concat(extracted) if extracted
17
28
  end
29
+ all_extracted_files
18
30
  end
19
31
 
20
32
  private
@@ -25,16 +37,17 @@ module EpubTools
25
37
 
26
38
  def extract_xhtmls_from(epub_path)
27
39
  epub_name = File.basename(epub_path, '.epub')
28
- puts "Extracting from #{epub_name}.epub" if @verbose
40
+ log "Extracting from #{epub_name}.epub"
29
41
  extracted_files = []
30
42
  Zip::File.open(epub_path) do |zip_file|
31
43
  zip_file.each do |entry|
32
44
  next unless entry.name.downcase.end_with?('.xhtml')
33
45
  next if File.basename(entry.name).downcase == 'nav.xhtml'
46
+
34
47
  output_path = File.join(@target_dir, "#{epub_name}_#{File.basename(entry.name)}")
35
48
  FileUtils.mkdir_p(File.dirname(output_path))
36
49
  entry.extract(output_path) { true }
37
- puts output_path if @verbose
50
+ log output_path
38
51
  extracted_files << output_path
39
52
  end
40
53
  end
@@ -43,4 +56,4 @@ module EpubTools
43
56
  warn "⚠️ Failed to process #{epub_path}: #{e.message}"
44
57
  end
45
58
  end
46
- end
59
+ end
data/lib/epub_tools.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require_relative 'epub_tools/version'
2
- require_relative 'epub_tools/add_chapters_to_epub'
3
- require_relative 'epub_tools/cli_helper'
2
+ require_relative 'epub_tools/loggable'
3
+ require_relative 'epub_tools/add_chapters'
4
4
  require_relative 'epub_tools/epub_initializer'
5
5
  require_relative 'epub_tools/split_chapters'
6
6
  require_relative 'epub_tools/xhtml_cleaner'
@@ -8,6 +8,8 @@ require_relative 'epub_tools/xhtml_extractor'
8
8
  require_relative 'epub_tools/pack_ebook'
9
9
  require_relative 'epub_tools/unpack_ebook'
10
10
  require_relative 'epub_tools/compile_book'
11
+ require_relative 'epub_tools/cli'
11
12
 
13
+ # Wrapper for all the other classes
12
14
  module EpubTools
13
15
  end
@@ -1,8 +1,8 @@
1
1
  require_relative 'test_helper'
2
- require_relative '../lib/epub_tools/add_chapters_to_epub'
2
+ require_relative '../lib/epub_tools/add_chapters'
3
3
  require 'nokogiri'
4
4
 
5
- class AddChaptersToEpubTest < Minitest::Test
5
+ class AddChaptersTest < Minitest::Test
6
6
  def setup
7
7
  @tmp = Dir.mktmpdir
8
8
  # Directories for chapters and EPUB OEBPS
@@ -54,12 +54,18 @@ class AddChaptersToEpubTest < Minitest::Test
54
54
 
55
55
  def test_run_moves_files_and_updates_opf_and_nav
56
56
  # Run the add chapters task
57
- EpubTools::AddChaptersToEpub.new(@chapters_dir, @epub_dir).run
57
+ result = EpubTools::AddChapters.new(chapters_dir: @chapters_dir, epub_dir: @epub_dir).run
58
+
59
+ # Check return value is an array of moved file basenames
60
+ assert_instance_of Array, result
61
+ assert_equal 2, result.size
62
+ assert_includes result, 'chapter_0.xhtml'
63
+ assert_includes result, 'chapter_1.xhtml'
58
64
 
59
65
  # Original chapter files should be moved
60
66
  assert_empty Dir.glob(File.join(@chapters_dir, '*.xhtml'))
61
- assert File.exist?(File.join(@epub_dir, 'chapter_0.xhtml'))
62
- assert File.exist?(File.join(@epub_dir, 'chapter_1.xhtml'))
67
+ assert_path_exists File.join(@epub_dir, 'chapter_0.xhtml')
68
+ assert_path_exists File.join(@epub_dir, 'chapter_1.xhtml')
63
69
 
64
70
  # package.opf should include manifest items and spine refs
65
71
  doc = Nokogiri::XML(File.read(@opf_file)) { |cfg| cfg.default_xml.noblanks }
@@ -81,12 +87,13 @@ class AddChaptersToEpubTest < Minitest::Test
81
87
  # strip namespaces for easy querying
82
88
  nav_doc.remove_namespaces!
83
89
  links = nav_doc.xpath('//nav/ol/li/a')
90
+
84
91
  assert_equal 2, links.size
85
92
  # First is Prologue (chapter_0)
86
93
  assert_equal 'chapter_0.xhtml', links[0]['href']
87
- assert_equal 'Prologue', links[0].text
94
+ assert_equal 'Prologue', links[0].text
88
95
  # Second is Chapter 1
89
96
  assert_equal 'chapter_1.xhtml', links[1]['href']
90
- assert_equal 'Chapter 1', links[1].text
97
+ assert_equal 'Chapter 1', links[1].text
91
98
  end
92
99
  end
@@ -0,0 +1,66 @@
1
+ require_relative '../test_helper'
2
+ require_relative '../../lib/epub_tools'
3
+
4
+ class CommandRegistryTest < Minitest::Test
5
+ class DummyCommand
6
+ attr_reader :options
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def run
13
+ true
14
+ end
15
+ end
16
+
17
+ def setup
18
+ @registry = EpubTools::CLI::CommandRegistry.new
19
+ end
20
+
21
+ def test_registry_initializes_empty
22
+ assert_empty @registry.commands
23
+ assert_empty @registry.available_commands
24
+ end
25
+
26
+ def test_register_command
27
+ @registry.register('test', DummyCommand, [:required_option], { default: 'value' })
28
+
29
+ assert_equal 1, @registry.commands.size
30
+ assert_includes @registry.available_commands, 'test'
31
+
32
+ command = @registry.get('test')
33
+
34
+ assert_equal DummyCommand, command[:class]
35
+ assert_equal [:required_option], command[:required_keys]
36
+ assert_equal({ default: 'value' }, command[:default_options])
37
+ end
38
+
39
+ def test_register_multiple_commands
40
+ @registry.register('test1', DummyCommand)
41
+ @registry.register('test2', DummyCommand)
42
+
43
+ assert_equal 2, @registry.commands.size
44
+ assert_includes @registry.available_commands, 'test1'
45
+ assert_includes @registry.available_commands, 'test2'
46
+ end
47
+
48
+ def test_get_nonexistent_command
49
+ assert_nil @registry.get('nonexistent')
50
+ end
51
+
52
+ def test_command_exists
53
+ @registry.register('test', DummyCommand)
54
+
55
+ assert @registry.command_exists?('test')
56
+ refute @registry.command_exists?('nonexistent')
57
+ end
58
+
59
+ def test_chained_registration
60
+ result = @registry.register('test1', DummyCommand)
61
+ .register('test2', DummyCommand)
62
+
63
+ assert_equal @registry, result
64
+ assert_equal 2, @registry.commands.size
65
+ end
66
+ end