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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbc80ed0690251633b8f12d80fa256922cac6092c12d3a910cd998e61ce38ea0
4
- data.tar.gz: b0da7dd401516d3a1074268c7495d02d1a3532ea3e6f4bd9b49b9e62772e280c
3
+ metadata.gz: 67e06b8c37f922205445fb328c172184eb14436d876514e306892e17faf767e2
4
+ data.tar.gz: b0eb25344c96cca09889bc7854b40f48dca2bc49449583a1259dd7ac30803309
5
5
  SHA512:
6
- metadata.gz: 66de9b943051874b87a1a32bf7fba1391eefad09e21a20f1e5a9265cdcb77d65bc453342787dc25944a265a81cf1119ecfe37a78713221d6d869aae4896982d9
7
- data.tar.gz: db68ab8011facd49ef7314d1b6e7ce0c2b645a62a3a4ae213048af2500546622b55153cc4598747d234a972c05738927ec7f9b9efb047feb0c60c3022a6f21fa
6
+ metadata.gz: b36393dbb0fcc6888a10a2bdb93d30fe88eb423034aacc7455bdb8338e4a0fccdfc8fac70b9998ccd66c1758956afcef0bd8ecd404aabe5eacfa4326b5f3b847
7
+ data.tar.gz: f9b2071dc6a832b178fa77c8982f43f645a9ebed4059cd023863cde8ed6220300df1155cfea39ee35455188b2fcd6672abf56f3682a5f46c606891e4e1bb0347
data/.document ADDED
@@ -0,0 +1,2 @@
1
+ lib
2
+ bin
@@ -2,20 +2,21 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: ["main"]
5
+ branches: [ main ]
6
6
  pull_request:
7
- branches: ["main"]
7
+ branches: [ main ]
8
8
 
9
9
  jobs:
10
10
  test:
11
11
  runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby: ['3.2', '3.3', '3.4']
12
15
  steps:
13
16
  - uses: actions/checkout@v3
14
17
  - uses: ruby/setup-ruby@v1
15
18
  with:
16
- ruby-version: '3.4.3'
17
- bundler-cache: true
18
- - name: Install dependencies
19
- run: bundle install --jobs 4 --retry 3
20
- - name: Run tests
21
- run: bundle exec rake test
19
+ ruby-version: ${{ matrix.ruby }}
20
+ - run: gem install bundler
21
+ - run: bundle install --jobs 4 --retry 3
22
+ - run: bundle exec rake test
data/.gitignore CHANGED
@@ -1,7 +1,11 @@
1
1
  .DS_Store
2
2
  /.bundle/
3
3
  /coverage/
4
+ /doc/
5
+ /.yardoc/
4
6
  vendor/bundle/
5
7
  *.gem
6
8
 
7
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
@@ -1,14 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- ruby '3.4.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
17
+ end
18
+
19
+ group :doc do
20
+ gem 'rdoc', '~> 6.13'
21
+ gem 'webrick', '~> 1.9'
22
+ gem 'yard', '~> 0.9.37'
14
23
  end
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,12 @@ 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)
75
+ yard (0.9.37)
31
76
 
32
77
  PLATFORMS
33
78
  aarch64-linux-gnu
@@ -43,8 +88,14 @@ DEPENDENCIES
43
88
  minitest (~> 5.25)
44
89
  nokogiri (~> 1.18)
45
90
  rake (~> 13.2)
91
+ rdoc (~> 6.13)
92
+ rubocop (~> 1.75)
93
+ rubocop-minitest (~> 0.38.0)
94
+ rubocop-rake (~> 0.7.1)
46
95
  rubyzip (~> 2.4)
47
96
  simplecov
97
+ webrick (~> 1.9)
98
+ yard (~> 0.9.37)
48
99
 
49
100
  RUBY VERSION
50
101
  ruby 3.4.3p32
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jaime Rodas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # EPUB Tools
2
2
 
3
- **TL;DR:** A Ruby gem and CLI for working with EPUB files: extract, split, initialize, add chapters, pack, and unpack EPUB books.
3
+ [![Build Status](https://github.com/jaimerodas/epub_tools/actions/workflows/ci.yml/badge.svg)](https://github.com/jaimerodas/epub_tools/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+
5
+ **TL;DR:** A Ruby gem and CLI for working with EPUB files: extract, split, initialize, add chapters, pack, and unpack EPUB books.
4
6
 
5
7
  ## Installation
6
8
  Install the gem via RubyGems:
@@ -52,7 +54,7 @@ epub-tools pack -i epub_dir -o MyBook.epub
52
54
 
53
55
  # Unpack EPUB
54
56
  epub-tools unpack -i MyBook.epub -o unpacked_dir
55
-
57
+
56
58
  # Full compile workflow: extract, split, initialize, add, and pack into one EPUB
57
59
  epub-tools compile -t "My Book" -a "Author Name" -s source_epubs -c cover.jpg -o MyBook.epub
58
60
  ```
@@ -88,7 +90,7 @@ EpubTools::EpubInitializer.new(
88
90
  ).run
89
91
 
90
92
  # Add chapters
91
- EpubTools::AddChaptersToEpub.new('chapters', 'epub_dir/OEBPS').run
93
+ EpubTools::AddChapters.new('chapters', 'epub_dir/OEBPS').run
92
94
 
93
95
  # Pack EPUB
94
96
  EpubTools::PackEbook.new('epub_dir', 'MyBook.epub').run
@@ -113,6 +115,22 @@ Enable coverage reporting:
113
115
  ```bash
114
116
  COVERAGE=true bundle exec rake test
115
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
+ ```
116
134
 
117
135
  ## Contributing
118
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::AddChaptersToEpub.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.0"
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
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env ruby
2
+ require 'nokogiri'
3
+ require 'fileutils'
4
+ require_relative 'loggable'
5
+
6
+ module EpubTools
7
+ # Moves new chapters into an unpacked EPUB
8
+ class AddChapters
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')
22
+ @opf_file = File.join(@epub_dir, 'package.opf')
23
+ @nav_file = File.join(@epub_dir, 'nav.xhtml')
24
+ @verbose = options[:verbose] || false
25
+
26
+ validate_directories!
27
+ end
28
+
29
+ # It works like this:
30
+ # - First, the *.xhtml files are moved from +chapters_dir+ over to +epub_dir+
31
+ # - Then, new entries will be added to the manifest and spine of the EPUB's +package.opf+ file.
32
+ # It will sort the files by extracting the chapter number.
33
+ # - Finally, it will update the +nav.xhtml+ file with the new chapters. Note that if there's a
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
36
+ def run
37
+ moved_files = move_chapters
38
+ update_package_opf(moved_files)
39
+ update_nav_xhtml(moved_files)
40
+ moved_files.each { |f| log("Moved: #{f}") }
41
+ moved_files
42
+ end
43
+
44
+ private
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
+
58
+ def move_chapters
59
+ # Sort by chapter number (numeric)
60
+ chapter_files = Dir.glob(File.join(@chapters_dir, '*.xhtml')).sort_by do |path|
61
+ # extract first integer from filename (e.g. chapter_10.xhtml -> 10)
62
+ File.basename(path)[/\d+/].to_i
63
+ end
64
+
65
+ raise ArgumentError, "No .xhtml files found in '#{@chapters_dir}'" if chapter_files.empty?
66
+
67
+ chapter_files.each do |file|
68
+ FileUtils.mv(file, @epub_dir)
69
+ end
70
+ chapter_files.map { |f| File.basename(f) }
71
+ end
72
+
73
+ def chapter_id(filename)
74
+ match = filename.match(/chapter_(\d+)\.xhtml/)
75
+ match ? "chap#{match[1]}" : File.basename(filename, '.xhtml')
76
+ end
77
+
78
+ def update_package_opf(filenames)
79
+ doc = Nokogiri::XML(File.read(@opf_file)) { |config| config.default_xml.noblanks }
80
+ manifest = doc.at_xpath('//xmlns:manifest')
81
+ spine = doc.at_xpath('//xmlns:spine')
82
+
83
+ filenames.each do |filename|
84
+ id = chapter_id(filename)
85
+ # Add <item> to the manifest if missing
86
+ unless doc.at_xpath("//xmlns:item[@href='#{filename}']")
87
+ item = Nokogiri::XML::Node.new('item', doc)
88
+ item['id'] = id
89
+ item['href'] = filename
90
+ item['media-type'] = 'application/xhtml+xml'
91
+ manifest.add_child(item)
92
+ end
93
+
94
+ # Add <itemref> to the spine if missing
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)
100
+ end
101
+
102
+ File.write(@opf_file, doc.to_xml(indent: 2))
103
+ end
104
+
105
+ def update_nav_xhtml(filenames)
106
+ doc = Nokogiri::XML(File.read(@nav_file)) { |config| config.default_xml.noblanks }
107
+ nav = doc.at_xpath('//xmlns:nav[@epub:type="toc"]/xmlns:ol')
108
+
109
+ filenames.each do |filename|
110
+ # Create a new <li><a href="...">Label</a></li> element
111
+ label = File.basename(filename, '.xhtml').gsub('_', ' ').capitalize
112
+ label = 'Prologue' if label == 'Chapter 0'
113
+ li = Nokogiri::XML::Node.new('li', doc)
114
+ a = Nokogiri::XML::Node.new('a', doc)
115
+ a['href'] = filename
116
+ a.content = label
117
+ li.add_child(a)
118
+ nav.add_child(li)
119
+ end
120
+
121
+ File.write(@nav_file, doc.to_xml(indent: 2))
122
+ end
123
+ end
124
+ end
@@ -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