epub_tools 0.3.1 → 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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +41 -0
  4. data/Gemfile +13 -9
  5. data/Gemfile.lock +49 -0
  6. data/README.md +16 -0
  7. data/bin/epub-tools +3 -109
  8. data/epub_tools.gemspec +6 -8
  9. data/lib/epub_tools/add_chapters.rb +41 -19
  10. data/lib/epub_tools/cli/command_registry.rb +47 -0
  11. data/lib/epub_tools/cli/option_builder.rb +164 -0
  12. data/lib/epub_tools/cli/runner.rb +164 -0
  13. data/lib/epub_tools/cli.rb +45 -0
  14. data/lib/epub_tools/compile_book.rb +60 -28
  15. data/lib/epub_tools/epub_initializer.rb +38 -28
  16. data/lib/epub_tools/loggable.rb +11 -0
  17. data/lib/epub_tools/pack_ebook.rb +18 -12
  18. data/lib/epub_tools/split_chapters.rb +31 -21
  19. data/lib/epub_tools/{text_style_class_finder.rb → style_finder.rb} +21 -17
  20. data/lib/epub_tools/unpack_ebook.rb +17 -12
  21. data/lib/epub_tools/version.rb +1 -1
  22. data/lib/epub_tools/xhtml_cleaner.rb +17 -13
  23. data/lib/epub_tools/xhtml_extractor.rb +20 -11
  24. data/lib/epub_tools.rb +2 -1
  25. data/test/add_chapters_test.rb +12 -5
  26. data/test/cli/command_registry_test.rb +66 -0
  27. data/test/cli/option_builder_test.rb +173 -0
  28. data/test/cli/runner_test.rb +91 -0
  29. data/test/cli_commands_test.rb +100 -0
  30. data/test/cli_test.rb +4 -0
  31. data/test/cli_version_test.rb +5 -3
  32. data/test/compile_book_test.rb +11 -2
  33. data/test/epub_initializer_test.rb +51 -31
  34. data/test/pack_ebook_test.rb +14 -8
  35. data/test/split_chapters_test.rb +22 -1
  36. data/test/{text_style_class_finder_test.rb → style_finder_test.rb} +7 -6
  37. data/test/test_helper.rb +4 -5
  38. data/test/unpack_ebook_test.rb +21 -5
  39. data/test/xhtml_cleaner_test.rb +13 -7
  40. data/test/xhtml_extractor_test.rb +17 -1
  41. metadata +19 -36
  42. data/lib/epub_tools/cli_helper.rb +0 -31
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 815d8970acc779f70f7d86395a3658a296536675539dfcf3e7f9a0e0f2d4153a
4
- data.tar.gz: da9131f5868b8e8c438e9f41c526d62a2d9f70a320e8d20fa14ffcf4a487f22c
3
+ metadata.gz: 67e06b8c37f922205445fb328c172184eb14436d876514e306892e17faf767e2
4
+ data.tar.gz: b0eb25344c96cca09889bc7854b40f48dca2bc49449583a1259dd7ac30803309
5
5
  SHA512:
6
- metadata.gz: a7dc1015fc79f4c72edec8fb672622122a3540ef3b8923683777a178d397fff5af4e0b0765f237e748ca7a103b9e4c2c0c0c0902d180fcd9cb87e90c613c862f
7
- data.tar.gz: fe60fa02c6a7db816b7aab2f5fe368de1a463fdaaa5aa1892146281362ea2cda49d904da541b67e34b2329225e01d9fcc7cd9a3a4c15503cb06cf3d6a10e26ec
6
+ metadata.gz: b36393dbb0fcc6888a10a2bdb93d30fe88eb423034aacc7455bdb8338e4a0fccdfc8fac70b9998ccd66c1758956afcef0bd8ecd404aabe5eacfa4326b5f3b847
7
+ data.tar.gz: f9b2071dc6a832b178fa77c8982f43f645a9ebed4059cd023863cde8ed6220300df1155cfea39ee35455188b2fcd6672abf56f3682a5f46c606891e4e1bb0347
data/.gitignore CHANGED
@@ -7,3 +7,5 @@ vendor/bundle/
7
7
  *.gem
8
8
 
9
9
  text_style_classes.yaml
10
+
11
+ **/.claude/settings.local.json
data/.rubocop.yml ADDED
@@ -0,0 +1,41 @@
1
+ # The behavior of RuboCop can be controlled via the .rubocop.yml
2
+ # configuration file. It makes it possible to enable/disable
3
+ # certain cops (checks) and to alter their behavior if they accept
4
+ # any parameters. The file can be placed either in your home
5
+ # directory or in some project directory.
6
+ #
7
+ # RuboCop will start looking for the configuration file in the directory
8
+ # where the inspected file is and continue its way up to the root directory.
9
+ #
10
+ # See https://docs.rubocop.org/rubocop/configuration
11
+
12
+ AllCops:
13
+ NewCops: enable
14
+
15
+ plugins:
16
+ - rubocop-minitest
17
+ - rubocop-rake
18
+
19
+ Style/FrozenStringLiteralComment:
20
+ Enabled: false
21
+
22
+ Metrics/MethodLength:
23
+ Enabled: false
24
+
25
+ Metrics/ClassLength:
26
+ Enabled: false
27
+
28
+ Metrics/AbcSize:
29
+ Enabled: false
30
+
31
+ Metrics/CyclomaticComplexity:
32
+ Enabled: false
33
+
34
+ Metrics/PerceivedComplexity:
35
+ Enabled: false
36
+
37
+ Style/OptionalBooleanParameter:
38
+ Enabled: false
39
+
40
+ Minitest/MultipleAssertions:
41
+ Enabled: false
data/Gemfile CHANGED
@@ -2,18 +2,22 @@
2
2
 
3
3
  ruby '>= 3.2'
4
4
 
5
- source "https://rubygems.org"
5
+ source 'https://rubygems.org'
6
6
 
7
- gem "nokogiri", "~> 1.18"
8
- gem "rake", "~> 13.2"
9
- gem "rubyzip", "~> 2.4"
7
+ gem 'nokogiri', '~> 1.18'
8
+ gem 'rake', '~> 13.2'
9
+ gem 'rubyzip', '~> 2.4'
10
10
 
11
- group :test do
12
- gem "minitest", "~> 5.25"
13
- gem "simplecov", require: false
11
+ group :test, :development do
12
+ gem 'minitest', '~> 5.25'
13
+ gem 'rubocop', '~> 1.75', require: false
14
+ gem 'rubocop-minitest', '~> 0.38.0', require: false
15
+ gem 'rubocop-rake', '~> 0.7.1', require: false
16
+ gem 'simplecov', require: false
14
17
  end
15
18
 
16
19
  group :doc do
17
- gem "yard", "~> 0.9.37"
20
+ gem 'rdoc', '~> 6.13'
21
+ gem 'webrick', '~> 1.9'
22
+ gem 'yard', '~> 0.9.37'
18
23
  end
19
-
data/Gemfile.lock CHANGED
@@ -1,7 +1,12 @@
1
1
  GEM
2
2
  remote: https://rubygems.org/
3
3
  specs:
4
+ ast (2.4.3)
5
+ date (3.4.1)
4
6
  docile (1.4.1)
7
+ json (2.11.3)
8
+ language_server-protocol (3.17.0.4)
9
+ lint_roller (1.1.0)
5
10
  minitest (5.25.5)
6
11
  nokogiri (1.18.8-aarch64-linux-gnu)
7
12
  racc (~> 1.4)
@@ -19,8 +24,42 @@ GEM
19
24
  racc (~> 1.4)
20
25
  nokogiri (1.18.8-x86_64-linux-musl)
21
26
  racc (~> 1.4)
27
+ parallel (1.27.0)
28
+ parser (3.3.8.0)
29
+ ast (~> 2.4.1)
30
+ racc
31
+ prism (1.4.0)
32
+ psych (5.2.3)
33
+ date
34
+ stringio
22
35
  racc (1.8.1)
36
+ rainbow (3.1.1)
23
37
  rake (13.2.1)
38
+ rdoc (6.13.1)
39
+ psych (>= 4.0.0)
40
+ regexp_parser (2.10.0)
41
+ rubocop (1.75.4)
42
+ json (~> 2.3)
43
+ language_server-protocol (~> 3.17.0.2)
44
+ lint_roller (~> 1.1.0)
45
+ parallel (~> 1.10)
46
+ parser (>= 3.3.0.2)
47
+ rainbow (>= 2.2.2, < 4.0)
48
+ regexp_parser (>= 2.9.3, < 3.0)
49
+ rubocop-ast (>= 1.44.0, < 2.0)
50
+ ruby-progressbar (~> 1.7)
51
+ unicode-display_width (>= 2.4.0, < 4.0)
52
+ rubocop-ast (1.44.1)
53
+ parser (>= 3.3.7.2)
54
+ prism (~> 1.4)
55
+ rubocop-minitest (0.38.0)
56
+ lint_roller (~> 1.1)
57
+ rubocop (>= 1.75.0, < 2.0)
58
+ rubocop-ast (>= 1.38.0, < 2.0)
59
+ rubocop-rake (0.7.1)
60
+ lint_roller (~> 1.1)
61
+ rubocop (>= 1.72.1)
62
+ ruby-progressbar (1.13.0)
24
63
  rubyzip (2.4.1)
25
64
  simplecov (0.22.0)
26
65
  docile (~> 1.1)
@@ -28,6 +67,11 @@ GEM
28
67
  simplecov_json_formatter (~> 0.1)
29
68
  simplecov-html (0.13.1)
30
69
  simplecov_json_formatter (0.1.4)
70
+ stringio (3.1.7)
71
+ unicode-display_width (3.1.4)
72
+ unicode-emoji (~> 4.0, >= 4.0.4)
73
+ unicode-emoji (4.0.4)
74
+ webrick (1.9.1)
31
75
  yard (0.9.37)
32
76
 
33
77
  PLATFORMS
@@ -44,8 +88,13 @@ DEPENDENCIES
44
88
  minitest (~> 5.25)
45
89
  nokogiri (~> 1.18)
46
90
  rake (~> 13.2)
91
+ rdoc (~> 6.13)
92
+ rubocop (~> 1.75)
93
+ rubocop-minitest (~> 0.38.0)
94
+ rubocop-rake (~> 0.7.1)
47
95
  rubyzip (~> 2.4)
48
96
  simplecov
97
+ webrick (~> 1.9)
49
98
  yard (~> 0.9.37)
50
99
 
51
100
  RUBY VERSION
data/README.md CHANGED
@@ -115,6 +115,22 @@ Enable coverage reporting:
115
115
  ```bash
116
116
  COVERAGE=true bundle exec rake test
117
117
  ```
118
+ ## Documentation
119
+
120
+ Detailed API documentation can be generated using YARD. To view the docs locally, serve the documentation locally with YARD:
121
+
122
+ ```bash
123
+ bundle exec yard server --reload
124
+ ```
125
+
126
+ Then navigate to http://localhost:8808 in your browser.
127
+
128
+ To (re)generate the documentation, install the documentation dependencies and run:
129
+
130
+ ```bash
131
+ bundle install --with doc
132
+ bundle exec yard doc
133
+ ```
118
134
 
119
135
  ## Contributing
120
136
  Pull requests welcome! Please open an issue for major changes.
data/bin/epub-tools CHANGED
@@ -1,112 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  require_relative '../lib/epub_tools'
3
3
 
4
- prog = File.basename($PROGRAM_NAME)
5
- # Global version flag: print version and exit if invoked as `epub-tools -v/--version`
6
- if ARGV[0] == '-v' || ARGV[0] == '--version'
7
- puts EpubTools::VERSION
8
- exit 0
9
- end
10
- script_dir = File.expand_path(File.join(__dir__, '..'))
11
- commands = %w[add extract init split pack unpack compile]
12
-
13
- if ARGV.empty? || !commands.include?(ARGV[0])
14
- puts <<~USAGE
15
- Usage: #{prog} COMMAND [options]
16
- Commands:
17
- init Initialize a bare-bones EPUB
18
- extract Extract XHTML files from EPUBs
19
- split Split XHTML into separate XHTMLs per chapter
20
- add Add chapter XHTML files into an EPUB
21
- pack Package an EPUB directory into a .epub file
22
- unpack Unpack an EPUB file into a directory
23
- compile Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB.
24
- USAGE
25
- exit 1
26
- end
27
-
28
- cmd = ARGV.shift
29
-
30
- case cmd
31
- when 'add'
32
- options = {}
33
- EpubTools::CLIHelper.parse(options, [:chapters_dir, :epub_oebps_dir]) do |opts, o|
34
- opts.banner = "Usage: #{prog} add [options]"
35
- opts.on('-c DIR', '--chapters-dir DIR', 'Chapters directory (required)') { |v| o[:chapters_dir] = v }
36
- opts.on('-e DIR', '--epub-oebps-dir DIR', 'EPUB OEBPS directory (required)') { |v| o[:epub_oebps_dir] = v }
37
- end
38
-
39
- EpubTools::AddChapters.new(options[:chapters_dir], options[:epub_oebps_dir]).run
40
-
41
- when 'extract'
42
- options = { verbose: true }
43
- EpubTools::CLIHelper.parse(options, [:source_dir, :target_dir]) do |opts, o|
44
- opts.banner = "Usage: #{prog} extract [options]"
45
- opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') { |v| o[:source_dir] = v }
46
- opts.on('-t DIR', '--target-dir DIR', 'Directory where the XHTML files will be extracted to (required)') { |v| o[:target_dir] = v }
47
- opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
48
- end
49
- EpubTools::XHTMLExtractor.new(
50
- source_dir: options[:source_dir],
51
- target_dir: options[:target_dir],
52
- verbose: options[:verbose]
53
- ).extract_all
54
-
55
- when 'split'
56
- options = { output_dir: './chapters', prefix: 'chapter', verbose: true }
57
- EpubTools::CLIHelper.parse(options, [:input_file, :book_title]) do |opts, o|
58
- opts.banner = "Usage: #{prog} split [options]"
59
- opts.on('-i FILE', '--input FILE', 'Source XHTML file (required)') { |v| options[:input_file] = v }
60
- opts.on('-t TITLE', '--title TITLE', 'Book title for HTML <title> tags (required)') { |v| options[:book_title] = v }
61
- opts.on('-o DIR', '--output-dir DIR', "Output directory for chapter files (default: #{options[:output_dir]})") { |v| options[:output_dir] = v }
62
- opts.on('-p PREFIX', '--prefix PREFIX', "Filename prefix for chapters (default: #{options[:prefix]})") { |v| options[:prefix] = v }
63
- opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
64
- end
65
- EpubTools::SplitChapters.new(options[:input_file], options[:book_title], options[:output_dir], options[:prefix], options[:verbose]).run
66
-
67
- when 'init'
68
- options = {}
69
- EpubTools::CLIHelper.parse(options, [:title, :author, :destination]) do |opts, o|
70
- opts.banner = "Usage: #{prog} init [options]"
71
- opts.on('-t TITLE', '--title TITLE', 'Book title (required)') { |v| o[:title] = v }
72
- opts.on('-a AUTHOR', '--author AUTHOR', 'Author name (required)') { |v| o[:author] = v }
73
- opts.on('-o DIR', '--output-dir DIR', 'Destination EPUB directory (required)') { |v| o[:destination] = v }
74
- opts.on('-c PATH', '--cover PATH', 'Cover image file path (optional)') { |v| o[:cover_image] = v }
75
- end
76
-
77
- EpubTools::EpubInitializer.new(options[:title], options[:author], options[:destination], options[:cover_image]).run
78
-
79
- when 'pack'
80
- options = {verbose: true}
81
- EpubTools::CLIHelper.parse(options, [:input_dir, :output_file]) do |opts, o|
82
- opts.banner = "Usage: #{prog} pack [options]"
83
- opts.on('-i DIR', '--input-dir DIR', 'EPUB directory to package (required)') { |v| o[:input_dir] = v }
84
- opts.on('-o FILE', '--output-file FILE', 'Output EPUB file path (required)') { |v| o[:output_file] = v }
85
- opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
86
- end
87
-
88
- EpubTools::PackEbook.new(options[:input_dir], options[:output_file], verbose: options[:verbose]).run
89
-
90
- when 'unpack'
91
- options = {verbose: true}
92
- EpubTools::CLIHelper.parse(options, [:epub_file]) do |opts, o|
93
- opts.banner = "Usage: #{prog} unpack [options]"
94
- opts.on('-i FILE', '--input-file FILE', 'EPUB file to unpack (required)') { |v| o[:epub_file] = v }
95
- opts.on('-o DIR', '--output-dir DIR', 'Output directory to extract into (default: basename of epub)') { |v| o[:output_dir] = v }
96
- opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
97
- end
98
- EpubTools::UnpackEbook.new(options[:epub_file], options[:output_dir], verbose: options[:verbose]).run
99
-
100
- when 'compile'
101
- options = {verbose: true}
102
- EpubTools::CLIHelper.parse(options, %i(title author source_dir)) do |opts, o|
103
- opts.banner = "Usage: #{prog} compile [options]"
104
- opts.on('-t TITLE', '--title TITLE', 'Book title (required)') { |v| o[:title] = v }
105
- opts.on('-a AUTHOR', '--author AUTHOR', 'Author name (required)') { |v| o[:author] = v }
106
- opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') { |v| o[:source_dir] = v }
107
- opts.on('-o FILE', '--output FILE', 'EPUB to create (default: book title in source dir)') { |v| o[:output_file] = v }
108
- opts.on('-c PATH', '--cover PATH', 'Cover image file path (optional)') { |v| o[:cover_image] = v }
109
- opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
110
- end
111
- EpubTools::CompileBook.new(**options).run
112
- end
4
+ # Use the new object-oriented CLI architecture
5
+ runner = EpubTools::CLI.create_runner
6
+ runner.run(ARGV)
data/epub_tools.gemspec CHANGED
@@ -11,16 +11,14 @@ Gem::Specification.new do |spec|
11
11
  spec.files = `git ls-files`.split("\n")
12
12
  spec.require_paths = ['lib']
13
13
  spec.executables = ['epub-tools']
14
- spec.required_ruby_version = ">= 3.2"
15
- spec.metadata = {
16
- "source_code_uri" => "https://github.com/jaimerodas/epub_tools/tree/main",
17
- "homepage_uri" => "https://github.com/jaimerodas/epub_tools"
14
+ spec.required_ruby_version = '>= 3.2'
15
+ spec.metadata = {
16
+ 'source_code_uri' => 'https://github.com/jaimerodas/epub_tools/tree/main',
17
+ 'homepage_uri' => 'https://github.com/jaimerodas/epub_tools',
18
+ 'rubygems_mfa_required' => 'true'
18
19
  }
19
20
 
20
21
  spec.add_dependency 'nokogiri', '~> 1.18'
21
- spec.add_dependency 'rubyzip', '~> 2.4'
22
22
  spec.add_dependency 'rake', '~> 13.2'
23
-
24
- spec.add_development_dependency 'minitest', '~> 5.25'
25
- spec.add_development_dependency 'simplecov', '~> 0'
23
+ spec.add_dependency 'rubyzip', '~> 2.4'
26
24
  end
@@ -1,24 +1,29 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'nokogiri'
3
3
  require 'fileutils'
4
+ require_relative 'loggable'
4
5
 
5
6
  module EpubTools
6
7
  # Moves new chapters into an unpacked EPUB
7
8
  class AddChapters
8
- # :args: chapters_dir, epub_dir, verbose:
9
- # [chapters_dir] Directory from which to move the xhtml chapters. It assumes the
10
- # directory will contain one or more files named +chapter_XX.xhtml+,
11
- # where +XX+ is a number. Defaults to +./chapters+.
12
- # [epub_dir] Unpacked EPUB directory to move the chapters to. It should be the same
13
- # directory that contains the +package.opf+ and +nav.xhtml+ files. Defaults
14
- # to +./epub/OEBPS+.
15
- # [verbose:] Whether to log progress to +STDOUT+ or not. Defaults to +false+.
16
- def initialize(chapters_dir = './chapters', epub_dir = './epub/OEBPS', verbose = false)
17
- @chapters_dir = chapters_dir
18
- @epub_dir = epub_dir
9
+ include Loggable
10
+ # Initializes the class
11
+ # @param options [Hash] Configuration options
12
+ # @option options [String] :chapters_dir Directory from which to move the xhtml chapters.
13
+ # It assumes the directory will contain one or more files named
14
+ # +chapter_XX.xhtml+, where +XX+ is a number. (default: './chapters')
15
+ # @option options [String] :epub_dir Unpacked EPUB directory to move the chapters to. It should
16
+ # be the same directory that contains the +package.opf+ and +nav.xhtml+
17
+ # files. (default: './epub/OEBPS')
18
+ # @option options [Boolean] :verbose Whether to log progress to STDOUT (default: false)
19
+ def initialize(options = {})
20
+ @chapters_dir = File.expand_path(options[:chapters_dir] || './chapters')
21
+ @epub_dir = File.expand_path(options[:epub_dir] || './epub/OEBPS')
19
22
  @opf_file = File.join(@epub_dir, 'package.opf')
20
23
  @nav_file = File.join(@epub_dir, 'nav.xhtml')
21
- @verbose = verbose
24
+ @verbose = options[:verbose] || false
25
+
26
+ validate_directories!
22
27
  end
23
28
 
24
29
  # It works like this:
@@ -27,21 +32,38 @@ module EpubTools
27
32
  # It will sort the files by extracting the chapter number.
28
33
  # - Finally, it will update the +nav.xhtml+ file with the new chapters. Note that if there's a
29
34
  # file named +chapter_0.xhtml+, it will be added to the +nav.xhtml+ as the Prologue.
35
+ # @return [Array<String>] List of moved chapter filenames
30
36
  def run
31
37
  moved_files = move_chapters
32
38
  update_package_opf(moved_files)
33
39
  update_nav_xhtml(moved_files)
34
- @verbose ? moved_files.each {|f| puts "Moved: #{f}"} : moved_files
40
+ moved_files.each { |f| log("Moved: #{f}") }
41
+ moved_files
35
42
  end
36
43
 
37
44
  private
38
45
 
46
+ def validate_directories!
47
+ raise ArgumentError, "Chapters directory '#{@chapters_dir}' does not exist" unless Dir.exist?(@chapters_dir)
48
+
49
+ raise ArgumentError, "EPUB directory '#{@epub_dir}' does not exist" unless Dir.exist?(@epub_dir)
50
+
51
+ raise ArgumentError, "EPUB package.opf file missing at '#{@opf_file}'" unless File.exist?(@opf_file)
52
+
53
+ return if File.exist?(@nav_file)
54
+
55
+ raise ArgumentError, "EPUB nav.xhtml file missing at '#{@nav_file}'"
56
+ end
57
+
39
58
  def move_chapters
40
59
  # Sort by chapter number (numeric)
41
60
  chapter_files = Dir.glob(File.join(@chapters_dir, '*.xhtml')).sort_by do |path|
42
61
  # extract first integer from filename (e.g. chapter_10.xhtml -> 10)
43
62
  File.basename(path)[/\d+/].to_i
44
63
  end
64
+
65
+ raise ArgumentError, "No .xhtml files found in '#{@chapters_dir}'" if chapter_files.empty?
66
+
45
67
  chapter_files.each do |file|
46
68
  FileUtils.mv(file, @epub_dir)
47
69
  end
@@ -70,11 +92,11 @@ module EpubTools
70
92
  end
71
93
 
72
94
  # Add <itemref> to the spine if missing
73
- unless doc.at_xpath("//xmlns:itemref[@idref='#{id}']")
74
- itemref = Nokogiri::XML::Node.new('itemref', doc)
75
- itemref['idref'] = id
76
- spine.add_child(itemref)
77
- end
95
+ next if doc.at_xpath("//xmlns:itemref[@idref='#{id}']")
96
+
97
+ itemref = Nokogiri::XML::Node.new('itemref', doc)
98
+ itemref['idref'] = id
99
+ spine.add_child(itemref)
78
100
  end
79
101
 
80
102
  File.write(@opf_file, doc.to_xml(indent: 2))
@@ -87,7 +109,7 @@ module EpubTools
87
109
  filenames.each do |filename|
88
110
  # Create a new <li><a href="...">Label</a></li> element
89
111
  label = File.basename(filename, '.xhtml').gsub('_', ' ').capitalize
90
- label = "Prologue" if label == "Chapter 0"
112
+ label = 'Prologue' if label == 'Chapter 0'
91
113
  li = Nokogiri::XML::Node.new('li', doc)
92
114
  a = Nokogiri::XML::Node.new('a', doc)
93
115
  a['href'] = filename
@@ -0,0 +1,47 @@
1
+ module EpubTools
2
+ module CLI
3
+ # Manages the registration and retrieval of commands
4
+ class CommandRegistry
5
+ attr_reader :commands
6
+
7
+ def initialize
8
+ @commands = {}
9
+ end
10
+
11
+ # Register a new command in the registry
12
+ # @param name [String] the command name
13
+ # @param command_class [Class] the class that implements the command
14
+ # @param required_keys [Array<Symbol>] keys that must be present in options
15
+ # @param default_options [Hash] default options for the command
16
+ # @return [self]
17
+ def register(name, command_class, required_keys = [], default_options = {})
18
+ @commands[name] = {
19
+ class: command_class,
20
+ required_keys: required_keys,
21
+ default_options: default_options
22
+ }
23
+ self
24
+ end
25
+
26
+ # Get a command by name
27
+ # @param name [String] the command name
28
+ # @return [Hash, nil] the command configuration or nil if not found
29
+ def get(name)
30
+ @commands[name]
31
+ end
32
+
33
+ # Get all available command names
34
+ # @return [Array<String>] list of registered command names
35
+ def available_commands
36
+ @commands.keys
37
+ end
38
+
39
+ # Check if a command is registered
40
+ # @param name [String] the command name
41
+ # @return [Boolean] true if command exists
42
+ def command_exists?(name)
43
+ @commands.key?(name)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,164 @@
1
+ require 'optparse'
2
+
3
+ module EpubTools
4
+ module CLI
5
+ # Builds and manages command line options
6
+ class OptionBuilder
7
+ attr_reader :options, :required_keys, :parser
8
+
9
+ # Initialize a new OptionBuilder
10
+ # @param default_options [Hash] Default options to start with
11
+ # @param required_keys [Array<Symbol>] Keys that must be present in the final options
12
+ def initialize(default_options = {}, required_keys = [])
13
+ @options = default_options.dup
14
+ @required_keys = required_keys
15
+ @parser = OptionParser.new
16
+ end
17
+
18
+ # Add banner to the option parser
19
+ # @param text [String] Banner text
20
+ # @return [self] for method chaining
21
+ def with_banner(text)
22
+ @parser.banner = text
23
+ self
24
+ end
25
+
26
+ # Add help option to the parser
27
+ # @return [self] for method chaining
28
+ def with_help_option
29
+ @parser.on('-h', '--help', 'Print this help') do
30
+ puts @parser
31
+ exit
32
+ end
33
+ self
34
+ end
35
+
36
+ # Add verbose option to the parser
37
+ # @return [self] for method chaining
38
+ def with_verbose_option
39
+ @options[:verbose] = true unless @options.key?(:verbose)
40
+ @parser.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| @options[:verbose] = !v }
41
+ self
42
+ end
43
+
44
+ # Add input file option to the parser
45
+ # @param description [String] Option description
46
+ # @param required [Boolean] Whether this option is required
47
+ # @return [self] for method chaining
48
+ def with_input_file(description = 'Input file', required = true)
49
+ desc = required ? "#{description} (required)" : description
50
+ @parser.on('-i FILE', '--input-file FILE', desc) { |v| @options[:input_file] = v }
51
+ self
52
+ end
53
+
54
+ # Add input directory option to the parser
55
+ # @param description [String] Option description
56
+ # @param required [Boolean] Whether this option is required
57
+ # @return [self] for method chaining
58
+ def with_input_dir(description = 'Input directory', required = true)
59
+ desc = required ? "#{description} (required)" : description
60
+ @parser.on('-i DIR', '--input-dir DIR', desc) { |v| @options[:input_dir] = v }
61
+ self
62
+ end
63
+
64
+ # Add output directory option to the parser
65
+ # @param description [String] Option description
66
+ # @param default [String, nil] Default value
67
+ # @return [self] for method chaining
68
+ def with_output_dir(description = 'Output directory', default = nil)
69
+ if default
70
+ desc = "#{description} (default: #{default})"
71
+ @options[:output_dir] = default unless @options.key?(:output_dir)
72
+ else
73
+ desc = "#{description} (required)"
74
+ end
75
+ @parser.on('-o DIR', '--output-dir DIR', desc) { |v| @options[:output_dir] = v }
76
+ self
77
+ end
78
+
79
+ # Add output file option to the parser
80
+ # @param description [String] Option description
81
+ # @param required [Boolean] Whether this option is required
82
+ # @return [self] for method chaining
83
+ def with_output_file(description = 'Output file', required = true)
84
+ desc = required ? "#{description} (required)" : description
85
+ @parser.on('-o FILE', '--output-file FILE', desc) { |v| @options[:output_file] = v }
86
+ self
87
+ end
88
+
89
+ # Add title option to the parser
90
+ # @return [self] for method chaining
91
+ def with_title_option
92
+ @parser.on('-t TITLE', '--title TITLE', 'Book title (required)') { |v| @options[:title] = v }
93
+ self
94
+ end
95
+
96
+ # Add author option to the parser
97
+ # @return [self] for method chaining
98
+ def with_author_option
99
+ @parser.on('-a AUTHOR', '--author AUTHOR', 'Author name (required)') { |v| @options[:author] = v }
100
+ self
101
+ end
102
+
103
+ # Add cover option to the parser
104
+ # @return [self] for method chaining
105
+ def with_cover_option
106
+ @parser.on('-c PATH', '--cover PATH', 'Cover image file path (optional)') { |v| @options[:cover_image] = v }
107
+ self
108
+ end
109
+
110
+ # Add a custom option to the parser
111
+ # @param short [String] Short option flag
112
+ # @param long [String] Long option flag
113
+ # @param description [String] Option description
114
+ # @param option_key [Symbol] Key in the options hash
115
+ # @param block [Proc] Optional block for custom processing
116
+ # @return [self] for method chaining
117
+ def with_option(short, long, description, option_key)
118
+ @parser.on(short, long, description) do |v|
119
+ @options[option_key] = block_given? ? yield(v) : v
120
+ end
121
+ self
122
+ end
123
+
124
+ # Add a custom block to configure options
125
+ # @yield [OptionParser, Hash] Yields the parser and options hash
126
+ # @return [self] for method chaining
127
+ def with_custom_options
128
+ yield @parser, @options if block_given?
129
+ self
130
+ end
131
+
132
+ # Parse the command line arguments
133
+ # @param args [Array<String>] Command line arguments
134
+ # @return [Hash] Parsed options
135
+ # @raise [SystemExit] If required options are missing
136
+ def parse(args = ARGV)
137
+ begin
138
+ @parser.parse!(args.dup)
139
+ validate_required_keys
140
+ rescue ArgumentError => e
141
+ abort "#{e.message}\n#{@parser}"
142
+ end
143
+ @options
144
+ end
145
+
146
+ private
147
+
148
+ # Validate that all required keys are present in the options
149
+ # @raise [SystemExit] If required options are missing
150
+ def validate_required_keys
151
+ return if @required_keys.empty?
152
+
153
+ missing = @required_keys.select { |k| @options[k].nil? }
154
+ return if missing.empty?
155
+
156
+ raise ArgumentError, "Missing required options: #{missing_keys(missing)}"
157
+ end
158
+
159
+ def missing_keys(keys)
160
+ keys.map { |k| "--#{k.to_s.tr('_', '-')}" }.join(', ')
161
+ end
162
+ end
163
+ end
164
+ end