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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.rubocop.yml +41 -0
- data/Gemfile +13 -9
- data/Gemfile.lock +49 -0
- data/README.md +16 -0
- data/bin/epub-tools +3 -109
- data/epub_tools.gemspec +6 -8
- data/lib/epub_tools/add_chapters.rb +41 -19
- data/lib/epub_tools/cli/command_registry.rb +47 -0
- data/lib/epub_tools/cli/option_builder.rb +164 -0
- data/lib/epub_tools/cli/runner.rb +164 -0
- data/lib/epub_tools/cli.rb +45 -0
- data/lib/epub_tools/compile_book.rb +60 -28
- data/lib/epub_tools/epub_initializer.rb +38 -28
- data/lib/epub_tools/loggable.rb +11 -0
- data/lib/epub_tools/pack_ebook.rb +18 -12
- data/lib/epub_tools/split_chapters.rb +31 -21
- data/lib/epub_tools/{text_style_class_finder.rb → style_finder.rb} +21 -17
- data/lib/epub_tools/unpack_ebook.rb +17 -12
- data/lib/epub_tools/version.rb +1 -1
- data/lib/epub_tools/xhtml_cleaner.rb +17 -13
- data/lib/epub_tools/xhtml_extractor.rb +20 -11
- data/lib/epub_tools.rb +2 -1
- data/test/add_chapters_test.rb +12 -5
- data/test/cli/command_registry_test.rb +66 -0
- data/test/cli/option_builder_test.rb +173 -0
- data/test/cli/runner_test.rb +91 -0
- data/test/cli_commands_test.rb +100 -0
- data/test/cli_test.rb +4 -0
- data/test/cli_version_test.rb +5 -3
- data/test/compile_book_test.rb +11 -2
- data/test/epub_initializer_test.rb +51 -31
- data/test/pack_ebook_test.rb +14 -8
- data/test/split_chapters_test.rb +22 -1
- data/test/{text_style_class_finder_test.rb → style_finder_test.rb} +7 -6
- data/test/test_helper.rb +4 -5
- data/test/unpack_ebook_test.rb +21 -5
- data/test/xhtml_cleaner_test.rb +13 -7
- data/test/xhtml_extractor_test.rb +17 -1
- metadata +19 -36
- data/lib/epub_tools/cli_helper.rb +0 -31
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 67e06b8c37f922205445fb328c172184eb14436d876514e306892e17faf767e2
|
4
|
+
data.tar.gz: b0eb25344c96cca09889bc7854b40f48dca2bc49449583a1259dd7ac30803309
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b36393dbb0fcc6888a10a2bdb93d30fe88eb423034aacc7455bdb8338e4a0fccdfc8fac70b9998ccd66c1758956afcef0bd8ecd404aabe5eacfa4326b5f3b847
|
7
|
+
data.tar.gz: f9b2071dc6a832b178fa77c8982f43f645a9ebed4059cd023863cde8ed6220300df1155cfea39ee35455188b2fcd6672abf56f3682a5f46c606891e4e1bb0347
|
data/.gitignore
CHANGED
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
|
5
|
+
source 'https://rubygems.org'
|
6
6
|
|
7
|
-
gem
|
8
|
-
gem
|
9
|
-
gem
|
7
|
+
gem 'nokogiri', '~> 1.18'
|
8
|
+
gem 'rake', '~> 13.2'
|
9
|
+
gem 'rubyzip', '~> 2.4'
|
10
10
|
|
11
|
-
group :test do
|
12
|
-
gem
|
13
|
-
gem
|
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
|
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
|
-
|
5
|
-
|
6
|
-
|
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 =
|
15
|
-
spec.metadata
|
16
|
-
|
17
|
-
|
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
|
-
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
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
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
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 =
|
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
|