epub_tools 0.4.1 → 0.6.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/.github/workflows/ci.yml +3 -0
- data/.rubocop.yml +10 -17
- data/CLAUDE.md +128 -0
- data/Gemfile +4 -4
- data/Gemfile.lock +39 -34
- data/README.md +37 -24
- data/Rakefile +2 -0
- data/bin/epub-tools +2 -0
- data/epub_tools.gemspec +3 -1
- data/lib/epub_tools/add_chapters.rb +64 -33
- data/lib/epub_tools/append_book.rb +81 -0
- data/lib/epub_tools/book_builder.rb +108 -0
- data/lib/epub_tools/chapter_marker_detector.rb +46 -0
- data/lib/epub_tools/chapter_validator.rb +50 -0
- data/lib/epub_tools/cli/command_options_configurator.rb +128 -0
- data/lib/epub_tools/cli/command_registry.rb +2 -0
- data/lib/epub_tools/cli/option_builder.rb +5 -3
- data/lib/epub_tools/cli/runner.rb +60 -110
- data/lib/epub_tools/cli.rb +17 -29
- data/lib/epub_tools/compile_book.rb +15 -146
- data/lib/epub_tools/compile_workspace.rb +40 -0
- data/lib/epub_tools/epub_configuration.rb +33 -0
- data/lib/epub_tools/epub_file_writer.rb +57 -0
- data/lib/epub_tools/epub_initializer.rb +83 -162
- data/lib/epub_tools/epub_metadata_builder.rb +92 -0
- data/lib/epub_tools/loggable.rb +2 -0
- data/lib/epub_tools/pack_ebook.rb +28 -14
- data/lib/epub_tools/split_chapters.rb +44 -56
- data/lib/epub_tools/style_finder.rb +17 -6
- data/lib/epub_tools/unpack_ebook.rb +20 -10
- data/lib/epub_tools/version.rb +3 -1
- data/lib/epub_tools/xhtml_cleaner.rb +1 -0
- data/lib/epub_tools/xhtml_extractor.rb +20 -10
- data/lib/epub_tools/xhtml_generator.rb +71 -0
- data/lib/epub_tools.rb +5 -0
- data/test/add_chapters_test.rb +119 -25
- data/test/append_book_test.rb +127 -0
- data/test/chapter_validator_test.rb +74 -0
- data/test/cli/command_registry_test.rb +2 -0
- data/test/cli/option_builder_test.rb +24 -14
- data/test/cli/runner_test.rb +15 -15
- data/test/cli_commands_test.rb +11 -0
- data/test/cli_test.rb +2 -0
- data/test/cli_version_test.rb +2 -0
- data/test/compile_book_test.rb +16 -102
- data/test/compile_workspace_test.rb +55 -0
- data/test/epub_initializer_test.rb +55 -27
- data/test/pack_ebook_test.rb +33 -9
- data/test/split_chapters_test.rb +96 -7
- data/test/style_finder_test.rb +2 -0
- data/test/test_helper.rb +2 -0
- data/test/unpack_ebook_test.rb +45 -20
- data/test/xhtml_cleaner_test.rb +2 -0
- data/test/xhtml_extractor_test.rb +3 -1
- metadata +17 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require_relative 'book_builder'
|
|
5
|
+
require_relative 'unpack_ebook'
|
|
6
|
+
|
|
7
|
+
module EpubTools
|
|
8
|
+
# Appends chapters from source EPUBs to an existing target EPUB
|
|
9
|
+
class AppendBook < BookBuilder
|
|
10
|
+
attr_reader :target_epub
|
|
11
|
+
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
super
|
|
14
|
+
@target_epub = File.expand_path(options.fetch(:target_epub))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def book_title
|
|
20
|
+
@book_title ||= read_target_title
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def output_path = @target_epub
|
|
24
|
+
|
|
25
|
+
def prepare_epub
|
|
26
|
+
backup_target
|
|
27
|
+
unpack_target
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def before_add_chapters
|
|
31
|
+
detect_conflicts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finalize_and_cleanup
|
|
35
|
+
log "Done. Updated EPUB: #{@target_epub} (backup: #{@backup_path})"
|
|
36
|
+
@workspace.clean
|
|
37
|
+
@target_epub
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def backup_target
|
|
41
|
+
@backup_path = "#{@target_epub}.bak"
|
|
42
|
+
log "Backing up target to '#{@backup_path}'..."
|
|
43
|
+
FileUtils.cp(@target_epub, @backup_path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unpack_target
|
|
47
|
+
log 'Unpacking target EPUB...'
|
|
48
|
+
UnpackEbook.new(epub_file: @target_epub, output_dir: @workspace.epub_dir, verbose: verbose).run
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_target_title
|
|
52
|
+
opf_path = File.join(epub_oebps_dir, 'package.opf')
|
|
53
|
+
doc = Nokogiri::XML(File.read(opf_path))
|
|
54
|
+
doc.remove_namespaces!
|
|
55
|
+
doc.at_xpath('//title')&.text || 'Untitled'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def detect_conflicts
|
|
59
|
+
new_numbers = chapter_numbers_in(@workspace.chapters_dir)
|
|
60
|
+
existing_numbers = chapter_numbers_in(epub_oebps_dir)
|
|
61
|
+
conflicts = new_numbers & existing_numbers
|
|
62
|
+
return if conflicts.empty?
|
|
63
|
+
|
|
64
|
+
formatted = conflicts.sort.map { |n| n == n.to_i ? n.to_i.to_s : n.to_s }
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"Chapter number conflict: chapters #{formatted.join(', ')} already exist in the target EPUB. " \
|
|
67
|
+
'Renumber the source chapters or remove conflicting chapters from the target.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def chapter_numbers_in(dir)
|
|
71
|
+
Dir.glob(File.join(dir, 'chapter_*.xhtml')).filter_map do |path|
|
|
72
|
+
basename = File.basename(path, '.xhtml')
|
|
73
|
+
if (m = basename.match(/_(\d+)_5\z/))
|
|
74
|
+
m[1].to_f + 0.5
|
|
75
|
+
elsif (m = basename.match(/_(\d+)\z/))
|
|
76
|
+
m[1].to_f
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'loggable'
|
|
5
|
+
require_relative 'xhtml_extractor'
|
|
6
|
+
require_relative 'split_chapters'
|
|
7
|
+
require_relative 'add_chapters'
|
|
8
|
+
require_relative 'pack_ebook'
|
|
9
|
+
require_relative 'compile_workspace'
|
|
10
|
+
require_relative 'chapter_validator'
|
|
11
|
+
|
|
12
|
+
module EpubTools
|
|
13
|
+
# Base class for book-building workflows (compile and append).
|
|
14
|
+
# Uses template method pattern — subclasses override hooks to customize behavior.
|
|
15
|
+
class BookBuilder
|
|
16
|
+
include Loggable
|
|
17
|
+
|
|
18
|
+
attr_reader :source_dir, :build_dir, :verbose
|
|
19
|
+
|
|
20
|
+
def initialize(options = {})
|
|
21
|
+
@source_dir = options.fetch(:source_dir)
|
|
22
|
+
@build_dir = options[:build_dir] || File.join(Dir.pwd, '.epub_tools_build')
|
|
23
|
+
@verbose = options[:verbose] || false
|
|
24
|
+
@workspace = CompileWorkspace.new(@build_dir)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Run the full build workflow
|
|
28
|
+
# @return [String] Path to the output EPUB file
|
|
29
|
+
def run
|
|
30
|
+
setup_workspace
|
|
31
|
+
prepare_epub
|
|
32
|
+
extract_xhtmls
|
|
33
|
+
split_xhtmls
|
|
34
|
+
validate_chapters
|
|
35
|
+
before_add_chapters
|
|
36
|
+
add_chapters
|
|
37
|
+
pack_epub
|
|
38
|
+
finalize_and_cleanup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Hook: called before extract/split to set up the EPUB target
|
|
44
|
+
def prepare_epub; end
|
|
45
|
+
|
|
46
|
+
# Hook: called after validation, before adding chapters
|
|
47
|
+
def before_add_chapters; end
|
|
48
|
+
|
|
49
|
+
# Subclasses must implement: the book title used when splitting chapters
|
|
50
|
+
def book_title
|
|
51
|
+
raise NotImplementedError, "#{self.class} must implement #book_title"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Subclasses must implement: the output file path for pack_epub
|
|
55
|
+
def output_path
|
|
56
|
+
raise NotImplementedError, "#{self.class} must implement #output_path"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def setup_workspace
|
|
60
|
+
@workspace.clean
|
|
61
|
+
@workspace.prepare_directories
|
|
62
|
+
log 'Preparing build directories...'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_xhtmls
|
|
66
|
+
log "Extracting XHTML files from EPUBs in '#{source_dir}'..."
|
|
67
|
+
XHTMLExtractor.new(source_dir: source_dir, target_dir: @workspace.xhtml_dir, verbose: verbose).run
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def split_xhtmls
|
|
71
|
+
Dir.glob(File.join(@workspace.xhtml_dir, '*.xhtml')).each { |f| split_xhtml_file(f) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def split_xhtml_file(xhtml_file)
|
|
75
|
+
log "Splitting '#{File.basename(xhtml_file, '.xhtml')}'..."
|
|
76
|
+
SplitChapters.new(
|
|
77
|
+
input_file: xhtml_file, book_title: book_title,
|
|
78
|
+
output_dir: @workspace.chapters_dir, output_prefix: 'chapter', verbose: verbose
|
|
79
|
+
).run
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_chapters
|
|
83
|
+
ChapterValidator.new(chapters_dir: @workspace.chapters_dir, verbose: verbose).validate
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def add_chapters
|
|
87
|
+
log 'Adding chapters to EPUB...'
|
|
88
|
+
AddChapters.new(
|
|
89
|
+
chapters_dir: @workspace.chapters_dir,
|
|
90
|
+
oebps_dir: epub_oebps_dir,
|
|
91
|
+
verbose: verbose
|
|
92
|
+
).run
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def pack_epub
|
|
96
|
+
log "Building EPUB '#{output_path}'..."
|
|
97
|
+
PackEbook.new(input_dir: @workspace.epub_dir, output_file: output_path, verbose: verbose).run
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def finalize_and_cleanup
|
|
101
|
+
log "Done. Output EPUB: #{File.expand_path(output_path)}"
|
|
102
|
+
@workspace.clean
|
|
103
|
+
output_path
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def epub_oebps_dir = File.join(@workspace.epub_dir, 'OEBPS')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EpubTools
|
|
4
|
+
# Detects chapter boundary markers in XHTML nodes.
|
|
5
|
+
# Recognizes: "Chapter N", "Chapter N (continued)", and "Prologue".
|
|
6
|
+
class ChapterMarkerDetector
|
|
7
|
+
# Tags that can contain chapter markers
|
|
8
|
+
MARKER_TAGS = %w[p span h2 h3 h4].freeze
|
|
9
|
+
# Tags that can contain prologue markers
|
|
10
|
+
PROLOGUE_TAGS = %w[h3 h4].freeze
|
|
11
|
+
|
|
12
|
+
# Detect what type of chapter marker a node represents
|
|
13
|
+
# @param node [Nokogiri::XML::Node] The XHTML node to check
|
|
14
|
+
# @return [Symbol, nil] :chapter, :continued, :prologue, or nil
|
|
15
|
+
def detect(node)
|
|
16
|
+
if continued_marker?(node)
|
|
17
|
+
:continued
|
|
18
|
+
elsif chapter_marker?(node)
|
|
19
|
+
:chapter
|
|
20
|
+
elsif prologue_marker?(node)
|
|
21
|
+
:prologue
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Extract the chapter number from a node's text
|
|
26
|
+
# @param node [Nokogiri::XML::Node] A node containing "Chapter N" text
|
|
27
|
+
# @return [Integer] The chapter number
|
|
28
|
+
def extract_chapter_number(node)
|
|
29
|
+
node.text.match(/Chapter\s+(\d+)/i)[1].to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def continued_marker?(node)
|
|
35
|
+
MARKER_TAGS.include?(node.name) && node.text.match?(/Chapter\s+\d+\s*\(continued\)/i)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def chapter_marker?(node)
|
|
39
|
+
MARKER_TAGS.include?(node.name) && node.text.match?(/Chapter\s+\d+/i) && !continued_marker?(node)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prologue_marker?(node)
|
|
43
|
+
PROLOGUE_TAGS.include?(node.name) && node.text.strip.match?(/\APrologue\z/i)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'loggable'
|
|
4
|
+
|
|
5
|
+
module EpubTools
|
|
6
|
+
# Validates chapter sequence completeness
|
|
7
|
+
class ChapterValidator
|
|
8
|
+
include Loggable
|
|
9
|
+
|
|
10
|
+
def initialize(chapters_dir:, verbose: false)
|
|
11
|
+
@chapters_dir = chapters_dir
|
|
12
|
+
@verbose = verbose
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Validates that integer chapter numbers form a complete sequence with no gaps.
|
|
16
|
+
# Half-chapters (e.g. chapter_5_5.xhtml) are recognized but not required.
|
|
17
|
+
# @raise [RuntimeError] if no chapter files are found or if integer chapters have gaps
|
|
18
|
+
def validate
|
|
19
|
+
log 'Validating chapter sequence...'
|
|
20
|
+
nums = extract_chapter_numbers
|
|
21
|
+
check_sequence_completeness(nums)
|
|
22
|
+
log "Chapter sequence is complete: #{nums.first} to #{nums.last}."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def extract_chapter_numbers
|
|
28
|
+
nums = Dir.glob(File.join(@chapters_dir, '*.xhtml')).filter_map do |file|
|
|
29
|
+
extract_chapter_number(File.basename(file, '.xhtml'))
|
|
30
|
+
end
|
|
31
|
+
raise "No chapter files found in #{@chapters_dir}" if nums.empty?
|
|
32
|
+
|
|
33
|
+
nums.sort.uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract_chapter_number(basename)
|
|
37
|
+
if (m = basename.match(/_(\d+)_5\z/))
|
|
38
|
+
m[1].to_i + 0.5
|
|
39
|
+
elsif (m = basename.match(/_(\d+)\z/))
|
|
40
|
+
m[1].to_i
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_sequence_completeness(sorted)
|
|
45
|
+
integers = sorted.select { |n| n == n.to_i }.map(&:to_i)
|
|
46
|
+
missing = (integers.first..integers.last).to_a - integers
|
|
47
|
+
raise "Missing chapter numbers: #{missing.join(' ')}" if missing.any?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EpubTools
|
|
4
|
+
module CLI
|
|
5
|
+
# Handles command-specific option configuration for CLI commands
|
|
6
|
+
class CommandOptionsConfigurator
|
|
7
|
+
# Configure command-specific options using dynamic dispatch
|
|
8
|
+
# @param cmd [String] Command name
|
|
9
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
10
|
+
def configure(cmd, builder)
|
|
11
|
+
method_name = "configure_#{cmd.tr('-', '_')}_options"
|
|
12
|
+
raise ArgumentError, "Unknown command: #{cmd}" unless respond_to?(method_name, true)
|
|
13
|
+
|
|
14
|
+
send(method_name, builder)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Configure options for the 'add' command
|
|
20
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
21
|
+
def configure_add_options(builder)
|
|
22
|
+
builder.with_custom_options do |opts, options|
|
|
23
|
+
opts.on('-c DIR', '--chapters-dir DIR', 'Chapters directory (required)') { |v| options[:chapters_dir] = v }
|
|
24
|
+
opts.on('-e DIR', '--oebps-dir DIR', 'EPUB OEBPS directory (required)') { |v| options[:oebps_dir] = v }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Configure options for the 'extract' command
|
|
29
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
30
|
+
def configure_extract_options(builder)
|
|
31
|
+
builder.with_custom_options do |opts, options|
|
|
32
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') do |v|
|
|
33
|
+
options[:source_dir] = v
|
|
34
|
+
end
|
|
35
|
+
opts.on('-t DIR', '--target-dir DIR',
|
|
36
|
+
'Directory where the XHTML files will be extracted to (required)') do |v|
|
|
37
|
+
options[:target_dir] = v
|
|
38
|
+
end
|
|
39
|
+
end.with_verbose_option
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Configure options for the 'split' command
|
|
43
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
44
|
+
def configure_split_options(builder)
|
|
45
|
+
builder.with_custom_options do |opts, options|
|
|
46
|
+
add_split_input_options(opts, options)
|
|
47
|
+
add_split_output_options(opts, options)
|
|
48
|
+
end.with_verbose_option
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_split_input_options(opts, options)
|
|
52
|
+
opts.on('-i FILE', '--input FILE', 'Source XHTML file (required)') { |v| options[:input_file] = v }
|
|
53
|
+
opts.on('-t TITLE', '--title TITLE', 'Book title for HTML <title> tags (required)') do |v|
|
|
54
|
+
options[:book_title] = v
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def add_split_output_options(opts, options)
|
|
59
|
+
opts.on('-o DIR', '--output-dir DIR',
|
|
60
|
+
"Output directory for chapter files (default: #{options[:output_dir]})") do |v|
|
|
61
|
+
options[:output_dir] = v
|
|
62
|
+
end
|
|
63
|
+
opts.on('-p PREFIX', '--prefix PREFIX', "Filename prefix for chapters (default: #{options[:prefix]})") do |v|
|
|
64
|
+
options[:prefix] = v
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Configure options for the 'init' command
|
|
69
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
70
|
+
def configure_init_options(builder)
|
|
71
|
+
builder.with_title_option
|
|
72
|
+
.with_author_option
|
|
73
|
+
.with_custom_options do |opts, options|
|
|
74
|
+
opts.on('-o DIR', '--output-dir DIR', 'Destination EPUB directory (required)') do |v|
|
|
75
|
+
options[:destination] = v
|
|
76
|
+
end
|
|
77
|
+
end.with_cover_option
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Configure options for the 'pack' command
|
|
81
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
82
|
+
def configure_pack_options(builder)
|
|
83
|
+
builder.with_input_dir('EPUB directory to package')
|
|
84
|
+
.with_output_file('Output EPUB file path')
|
|
85
|
+
.with_verbose_option
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Configure options for the 'unpack' command
|
|
89
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
90
|
+
def configure_unpack_options(builder)
|
|
91
|
+
builder.with_custom_options do |opts, options|
|
|
92
|
+
opts.on('-i FILE', '--input-file FILE', 'EPUB file to unpack (required)') { |v| options[:epub_file] = v }
|
|
93
|
+
opts.on('-o DIR', '--output-dir DIR', 'Output directory to extract into (default: basename of epub)') do |v|
|
|
94
|
+
options[:output_dir] = v
|
|
95
|
+
end
|
|
96
|
+
end.with_verbose_option
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Configure options for the 'append' command
|
|
100
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
101
|
+
def configure_append_options(builder)
|
|
102
|
+
builder.with_custom_options do |opts, options|
|
|
103
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to append (required)') do |v|
|
|
104
|
+
options[:source_dir] = v
|
|
105
|
+
end
|
|
106
|
+
opts.on('-t FILE', '--target-epub FILE', 'Existing EPUB file to append to (required)') do |v|
|
|
107
|
+
options[:target_epub] = v
|
|
108
|
+
end
|
|
109
|
+
end.with_verbose_option
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Configure options for the 'compile' command
|
|
113
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
114
|
+
def configure_compile_options(builder)
|
|
115
|
+
builder.with_title_option
|
|
116
|
+
.with_author_option
|
|
117
|
+
.with_custom_options do |opts, options|
|
|
118
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') do |v|
|
|
119
|
+
options[:source_dir] = v
|
|
120
|
+
end
|
|
121
|
+
opts.on('-o FILE', '--output FILE', 'EPUB to create (default: book title in source dir)') do |v|
|
|
122
|
+
options[:output_file] = v
|
|
123
|
+
end
|
|
124
|
+
end.with_cover_option.with_verbose_option
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'optparse'
|
|
2
4
|
|
|
3
5
|
module EpubTools
|
|
@@ -45,7 +47,7 @@ module EpubTools
|
|
|
45
47
|
# @param description [String] Option description
|
|
46
48
|
# @param required [Boolean] Whether this option is required
|
|
47
49
|
# @return [self] for method chaining
|
|
48
|
-
def with_input_file(description = 'Input file', required
|
|
50
|
+
def with_input_file(description = 'Input file', required: true)
|
|
49
51
|
desc = required ? "#{description} (required)" : description
|
|
50
52
|
@parser.on('-i FILE', '--input-file FILE', desc) { |v| @options[:input_file] = v }
|
|
51
53
|
self
|
|
@@ -55,7 +57,7 @@ module EpubTools
|
|
|
55
57
|
# @param description [String] Option description
|
|
56
58
|
# @param required [Boolean] Whether this option is required
|
|
57
59
|
# @return [self] for method chaining
|
|
58
|
-
def with_input_dir(description = 'Input directory', required
|
|
60
|
+
def with_input_dir(description = 'Input directory', required: true)
|
|
59
61
|
desc = required ? "#{description} (required)" : description
|
|
60
62
|
@parser.on('-i DIR', '--input-dir DIR', desc) { |v| @options[:input_dir] = v }
|
|
61
63
|
self
|
|
@@ -80,7 +82,7 @@ module EpubTools
|
|
|
80
82
|
# @param description [String] Option description
|
|
81
83
|
# @param required [Boolean] Whether this option is required
|
|
82
84
|
# @return [self] for method chaining
|
|
83
|
-
def with_output_file(description = 'Output file', required
|
|
85
|
+
def with_output_file(description = 'Output file', required: true)
|
|
84
86
|
desc = required ? "#{description} (required)" : description
|
|
85
87
|
@parser.on('-o FILE', '--output-file FILE', desc) { |v| @options[:output_file] = v }
|
|
86
88
|
self
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative 'command_registry'
|
|
2
4
|
require_relative 'option_builder'
|
|
5
|
+
require_relative 'command_options_configurator'
|
|
3
6
|
|
|
4
7
|
module EpubTools
|
|
5
8
|
module CLI
|
|
@@ -12,24 +15,15 @@ module EpubTools
|
|
|
12
15
|
def initialize(program_name = nil)
|
|
13
16
|
@registry = CommandRegistry.new
|
|
14
17
|
@program_name = program_name || File.basename($PROGRAM_NAME)
|
|
18
|
+
@options_configurator = CommandOptionsConfigurator.new
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Run the CLI application
|
|
18
22
|
# @param args [Array<String>] Command line arguments
|
|
19
23
|
# @return [Boolean] true if the command was run successfully
|
|
20
24
|
def run(args = ARGV)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
puts EpubTools::VERSION
|
|
24
|
-
exit 0
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
commands = registry.available_commands
|
|
28
|
-
|
|
29
|
-
if args.empty? || !commands.include?(args[0])
|
|
30
|
-
print_usage(commands)
|
|
31
|
-
exit 1
|
|
32
|
-
end
|
|
25
|
+
handle_version_flag(args)
|
|
26
|
+
validate_command_args(args)
|
|
33
27
|
|
|
34
28
|
cmd = args.shift
|
|
35
29
|
handle_command(cmd, args)
|
|
@@ -43,6 +37,36 @@ module EpubTools
|
|
|
43
37
|
command_config = registry.get(cmd)
|
|
44
38
|
return false unless command_config
|
|
45
39
|
|
|
40
|
+
builder = build_option_parser(cmd, command_config)
|
|
41
|
+
execute_command(command_config, builder, args)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Handle version flag and exit if present
|
|
47
|
+
# @param args [Array<String>] Command line arguments
|
|
48
|
+
def handle_version_flag(args)
|
|
49
|
+
return unless ['-v', '--version'].include?(args[0])
|
|
50
|
+
|
|
51
|
+
puts EpubTools::VERSION
|
|
52
|
+
exit 0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validate command arguments and exit if invalid
|
|
56
|
+
# @param args [Array<String>] Command line arguments
|
|
57
|
+
def validate_command_args(args)
|
|
58
|
+
commands = registry.available_commands
|
|
59
|
+
return unless args.empty? || !commands.include?(args[0])
|
|
60
|
+
|
|
61
|
+
print_usage(commands)
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build option parser for a command
|
|
66
|
+
# @param cmd [String] Command name
|
|
67
|
+
# @param command_config [Hash] Command configuration
|
|
68
|
+
# @return [OptionBuilder] Configured option builder
|
|
69
|
+
def build_option_parser(cmd, command_config)
|
|
46
70
|
options = command_config[:default_options].dup
|
|
47
71
|
required_keys = command_config[:required_keys]
|
|
48
72
|
|
|
@@ -50,114 +74,40 @@ module EpubTools
|
|
|
50
74
|
.with_banner("Usage: #{program_name} #{cmd} [options]")
|
|
51
75
|
.with_help_option
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
@options_configurator.configure(cmd, builder)
|
|
78
|
+
builder
|
|
79
|
+
end
|
|
55
80
|
|
|
56
|
-
|
|
81
|
+
# Execute a command with parsed options
|
|
82
|
+
# @param command_config [Hash] Command configuration
|
|
83
|
+
# @param builder [OptionBuilder] Option builder instance
|
|
84
|
+
# @param args [Array<String>] Command line arguments
|
|
85
|
+
# @return [Object] Command instance
|
|
86
|
+
def execute_command(command_config, builder, args)
|
|
57
87
|
options = builder.parse(args)
|
|
58
88
|
command_class = command_config[:class]
|
|
59
|
-
command_class.new(options)
|
|
60
|
-
|
|
89
|
+
command_instance = command_class.new(options)
|
|
90
|
+
command_instance.run
|
|
91
|
+
command_instance
|
|
61
92
|
end
|
|
62
93
|
|
|
63
|
-
private
|
|
64
|
-
|
|
65
94
|
# Print usage information
|
|
66
95
|
# @param commands [Array<String>] Available commands
|
|
67
96
|
def print_usage(_commands)
|
|
68
|
-
puts
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
init Initialize a bare-bones EPUB
|
|
72
|
-
extract Extract XHTML files from EPUBs
|
|
73
|
-
split Split XHTML into separate XHTMLs per chapter
|
|
74
|
-
add Add chapter XHTML files into an EPUB
|
|
75
|
-
pack Package an EPUB directory into a .epub file
|
|
76
|
-
unpack Unpack an EPUB file into a directory
|
|
77
|
-
compile Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB.
|
|
78
|
-
USAGE
|
|
97
|
+
puts "Usage: #{program_name} COMMAND [options]"
|
|
98
|
+
puts 'Commands:'
|
|
99
|
+
print_command_list
|
|
79
100
|
end
|
|
80
101
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
options[:oebps_dir] = v
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
when 'extract'
|
|
95
|
-
builder.with_custom_options do |opts, options|
|
|
96
|
-
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') do |v|
|
|
97
|
-
options[:source_dir] = v
|
|
98
|
-
end
|
|
99
|
-
opts.on('-t DIR', '--target-dir DIR',
|
|
100
|
-
'Directory where the XHTML files will be extracted to (required)') do |v|
|
|
101
|
-
options[:target_dir] = v
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
.with_verbose_option
|
|
105
|
-
|
|
106
|
-
when 'split'
|
|
107
|
-
builder.with_custom_options do |opts, options|
|
|
108
|
-
opts.on('-i FILE', '--input FILE', 'Source XHTML file (required)') { |v| options[:input_file] = v }
|
|
109
|
-
opts.on('-t TITLE', '--title TITLE', 'Book title for HTML <title> tags (required)') do |v|
|
|
110
|
-
options[:book_title] = v
|
|
111
|
-
end
|
|
112
|
-
opts.on('-o DIR', '--output-dir DIR',
|
|
113
|
-
"Output directory for chapter files (default: #{options[:output_dir]})") do |v|
|
|
114
|
-
options[:output_dir] = v
|
|
115
|
-
end
|
|
116
|
-
opts.on('-p PREFIX', '--prefix PREFIX',
|
|
117
|
-
"Filename prefix for chapters (default: #{options[:prefix]})") do |v|
|
|
118
|
-
options[:prefix] = v
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
.with_verbose_option
|
|
122
|
-
|
|
123
|
-
when 'init'
|
|
124
|
-
builder.with_title_option
|
|
125
|
-
.with_author_option
|
|
126
|
-
.with_custom_options do |opts, options|
|
|
127
|
-
opts.on('-o DIR', '--output-dir DIR', 'Destination EPUB directory (required)') do |v|
|
|
128
|
-
options[:destination] = v
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
.with_cover_option
|
|
132
|
-
|
|
133
|
-
when 'pack'
|
|
134
|
-
builder.with_input_dir('EPUB directory to package')
|
|
135
|
-
.with_output_file('Output EPUB file path')
|
|
136
|
-
.with_verbose_option
|
|
137
|
-
|
|
138
|
-
when 'unpack'
|
|
139
|
-
builder.with_custom_options do |opts, options|
|
|
140
|
-
opts.on('-i FILE', '--input-file FILE', 'EPUB file to unpack (required)') { |v| options[:epub_file] = v }
|
|
141
|
-
opts.on('-o DIR', '--output-dir DIR', 'Output directory to extract into (default: basename of epub)') do |v|
|
|
142
|
-
options[:output_dir] = v
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
.with_verbose_option
|
|
146
|
-
|
|
147
|
-
when 'compile'
|
|
148
|
-
builder.with_title_option
|
|
149
|
-
.with_author_option
|
|
150
|
-
.with_custom_options do |opts, options|
|
|
151
|
-
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') do |v|
|
|
152
|
-
options[:source_dir] = v
|
|
153
|
-
end
|
|
154
|
-
opts.on('-o FILE', '--output FILE', 'EPUB to create (default: book title in source dir)') do |v|
|
|
155
|
-
options[:output_file] = v
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
.with_cover_option
|
|
159
|
-
.with_verbose_option
|
|
160
|
-
end
|
|
102
|
+
def print_command_list
|
|
103
|
+
puts ' init Initialize a bare-bones EPUB'
|
|
104
|
+
puts ' extract Extract XHTML files from EPUBs'
|
|
105
|
+
puts ' split Split XHTML into separate XHTMLs per chapter'
|
|
106
|
+
puts ' add Add chapter XHTML files into an EPUB'
|
|
107
|
+
puts ' pack Package an EPUB directory into a .epub file'
|
|
108
|
+
puts ' unpack Unpack an EPUB file into a directory'
|
|
109
|
+
puts ' compile Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB.'
|
|
110
|
+
puts ' append Extracts and splits EPUBs from a dir and appends them to an existing EPUB.'
|
|
161
111
|
end
|
|
162
112
|
end
|
|
163
113
|
end
|