Skip to content

Commit 64affe2

Browse files
committed
Add exact output file conversion
1 parent e725fb4 commit 64affe2

4 files changed

Lines changed: 339 additions & 16 deletions

File tree

cmd/fbc/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func main() {
166166
Usage: "for Kindle formats generate as ebook (EBOK) instead of personal document (PDOC)",
167167
},
168168
&cli.StringFlag{Name: "asin", Usage: "set ASIN (10 chars, A-Z0-9); used only for Kindle formats"},
169+
&cli.StringFlag{Name: "output-file", Aliases: []string{"o"}, Usage: "write single-book conversion to exact output `FILE`"},
169170
&cli.BoolFlag{Name: "nodirs", Aliases: []string{"nd"}, Usage: "when producing output do not keep input directory structure"},
170171
&cli.BoolFlag{Name: "overwrite", Aliases: []string{"ow"}, Usage: "continue even if destination exists, overwrite files"},
171172
&cli.StringFlag{Name: "force-zip-cp",
@@ -186,6 +187,10 @@ SOURCE:
186187
DESTINATION:
187188
always a path, output file name(s) and extension will be derived from other parameters
188189
if absent - current working directory
190+
191+
OUTPUT FILE:
192+
--output-file/-o writes a single SOURCE book to exact output FILE. It cannot be used with DESTINATION
193+
or recursive directory/archive conversion.
189194
`, cli.CommandHelpTemplate),
190195
},
191196
{

convert/run.go

Lines changed: 182 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,29 @@ func Run(ctx context.Context, cmd *cli.Command) (err error) {
5151
return err
5252
}
5353

54+
outputFile := cmd.String("output-file")
55+
if outputFile != "" {
56+
if cmd.Args().Len() > 1 {
57+
return errors.New("--output-file cannot be used with DESTINATION")
58+
}
59+
outputFile, err = filepath.Abs(outputFile)
60+
if err != nil {
61+
return err
62+
}
63+
}
64+
5465
dst := cmd.Args().Get(1)
55-
if len(dst) == 0 {
66+
if len(dst) == 0 && outputFile == "" {
5667
if dst, err = os.Getwd(); err != nil {
5768
return fmt.Errorf("unable to get working directory: %w", err)
5869
}
5970
}
60-
if dst, err = filepath.Abs(dst); err != nil {
61-
return err
71+
if dst != "" {
72+
if dst, err = filepath.Abs(dst); err != nil {
73+
return err
74+
}
6275
}
63-
if cmd.Args().Len() > 2 {
76+
if outputFile == "" && cmd.Args().Len() > 2 {
6477
log.Warn("Mailformed command line, too many destinations", zap.Strings("ignoring", cmd.Args().Slice()[2:]))
6578
}
6679

@@ -139,11 +152,21 @@ func Run(ctx context.Context, cmd *cli.Command) (err error) {
139152
}
140153
}
141154

142-
log.Info("Processing starting", zap.String("source", src), zap.String("destination", dst), zap.Stringer("format", format))
155+
logDestination := dst
156+
if outputFile != "" {
157+
logDestination = outputFile
158+
}
159+
log.Info("Processing starting", zap.String("source", src), zap.String("destination", logDestination), zap.Stringer("format", format))
160+
if outputFile != "" {
161+
log.Info("Using exact output file", zap.String("output_file", outputFile))
162+
}
143163
defer func(start time.Time) {
144164
log.Info("Processing completed", zap.Duration("elapsed", time.Since(start)))
145165
}(time.Now())
146166

167+
if outputFile != "" {
168+
return processOutputFile(ctx, src, outputFile, format, log)
169+
}
147170
return process(ctx, src, dst, format, log)
148171
}
149172

@@ -208,7 +231,7 @@ func process(ctx context.Context, src, dst string, format common.OutputFmt, log
208231
return fmt.Errorf("unable to process file %q: %w", head, err)
209232
}
210233
defer file.Close()
211-
if err := processBook(ctx, selectReader(file, enc), filepath.Base(head), dst, format, log); err != nil {
234+
if err := processBook(ctx, selectReader(file, enc), filepath.Base(head), dst, format, log, processBookOptions{}); err != nil {
212235
return fmt.Errorf("unable to process file %q: %w", head, err)
213236
}
214237
break
@@ -222,6 +245,139 @@ func process(ctx context.Context, src, dst string, format common.OutputFmt, log
222245
return nil
223246
}
224247

248+
func processOutputFile(ctx context.Context, src, outputFile string, format common.OutputFmt, log *zap.Logger) error {
249+
var head, tail string
250+
for head = src; len(head) != 0; head, tail = filepath.Split(head) {
251+
if err := ctx.Err(); err != nil {
252+
return err
253+
}
254+
255+
head = strings.TrimSuffix(head, string(filepath.Separator))
256+
257+
fi, err := os.Stat(head)
258+
if err != nil {
259+
continue
260+
}
261+
262+
if fi.Mode().IsDir() {
263+
if len(tail) != 0 {
264+
return fmt.Errorf("input source was not found (%s) => (%s)", head, strings.TrimPrefix(src, head))
265+
}
266+
return errors.New("--output-file requires SOURCE to resolve to a single FB2 book, not a directory")
267+
}
268+
269+
if !fi.Mode().IsRegular() {
270+
return fmt.Errorf("unexpected path mode for (%s) => (%s)", head, strings.TrimPrefix(src, head))
271+
}
272+
273+
archiveFile, err := isArchiveFile(head)
274+
if err != nil {
275+
return fmt.Errorf("unable to check archive type: %w", err)
276+
}
277+
if archiveFile {
278+
tail = strings.TrimPrefix(strings.TrimPrefix(src, head), string(filepath.Separator))
279+
if err := processSingleBookArchiveOutputFile(ctx, head, tail, outputFile, format, log); err != nil {
280+
return fmt.Errorf("unable to process archive: %w", err)
281+
}
282+
return nil
283+
}
284+
285+
book, enc, err := isBookFile(head)
286+
if err != nil {
287+
return fmt.Errorf("unable to check file type: %w", err)
288+
}
289+
if book && len(tail) == 0 {
290+
file, err := os.Open(head)
291+
if err != nil {
292+
return fmt.Errorf("unable to process file %q: %w", head, err)
293+
}
294+
defer file.Close()
295+
if err := processBook(
296+
ctx,
297+
selectReader(file, enc),
298+
filepath.Base(head),
299+
filepath.Dir(outputFile),
300+
format,
301+
log,
302+
processBookOptions{OutputFile: outputFile},
303+
); err != nil {
304+
return fmt.Errorf("unable to process file %q: %w", head, err)
305+
}
306+
return nil
307+
}
308+
return fmt.Errorf("input was not recognized as a single FB2 book (%s)", head)
309+
}
310+
return fmt.Errorf("input source was not found (%s)", src)
311+
}
312+
313+
func processSingleBookArchiveOutputFile(
314+
ctx context.Context,
315+
archivePath string,
316+
pathInArchive string,
317+
outputFile string,
318+
format common.OutputFmt,
319+
log *zap.Logger,
320+
) error {
321+
var matches []string
322+
if err := archive.Walk(archivePath, pathInArchive, func(_ string, f *zip.File) error {
323+
if err := ctx.Err(); err != nil {
324+
return err
325+
}
326+
book, _, err := isBookInArchive(f)
327+
if err != nil {
328+
return err
329+
}
330+
if book {
331+
matches = append(matches, f.FileHeader.Name)
332+
}
333+
return nil
334+
}); err != nil {
335+
return err
336+
}
337+
if len(matches) == 0 {
338+
return fmt.Errorf("no FB2 books found in archive path %q", pathInArchive)
339+
}
340+
if len(matches) > 1 {
341+
return fmt.Errorf("--output-file requires archive SOURCE to resolve to one FB2 book, found %d", len(matches))
342+
}
343+
344+
r, err := zip.OpenReader(archivePath)
345+
if err != nil {
346+
return err
347+
}
348+
defer r.Close()
349+
for _, f := range r.File {
350+
if f.FileHeader.Name != matches[0] {
351+
continue
352+
}
353+
book, enc, err := isBookInArchive(f)
354+
if err != nil {
355+
return err
356+
}
357+
if !book {
358+
break
359+
}
360+
reader, err := f.Open()
361+
if err != nil {
362+
return fmt.Errorf("open archive entry: %w", err)
363+
}
364+
err = processBook(
365+
ctx,
366+
selectReader(reader, enc),
367+
f.FileHeader.Name,
368+
filepath.Dir(outputFile),
369+
format,
370+
log,
371+
processBookOptions{OutputFile: outputFile},
372+
)
373+
if closeErr := reader.Close(); closeErr != nil {
374+
err = errors.Join(err, fmt.Errorf("close archive entry: %w", closeErr))
375+
}
376+
return err
377+
}
378+
return fmt.Errorf("archive entry %q disappeared", matches[0])
379+
}
380+
225381
// processDir walks directory tree finding fb2 files and processes them.
226382
func processDir(ctx context.Context, dir, dst string, format common.OutputFmt, log *zap.Logger) (err error) {
227383
count := 0
@@ -351,7 +507,7 @@ func processBookFile(ctx context.Context, path, src, dst string, enc srcEncoding
351507
return fmt.Errorf("open file: %w", err)
352508
}
353509

354-
err = processBook(ctx, selectReader(file, enc), src, dst, format, log)
510+
err = processBook(ctx, selectReader(file, enc), src, dst, format, log, processBookOptions{})
355511
if closeErr := file.Close(); closeErr != nil {
356512
err = errors.Join(err, fmt.Errorf("close file: %w", closeErr))
357513
}
@@ -364,7 +520,7 @@ func processBookArchiveEntry(ctx context.Context, f *zip.File, enc srcEncoding,
364520
return fmt.Errorf("open archive entry: %w", err)
365521
}
366522

367-
err = processBook(ctx, selectReader(r, enc), src, dst, format, log)
523+
err = processBook(ctx, selectReader(r, enc), src, dst, format, log, processBookOptions{})
368524
if closeErr := r.Close(); closeErr != nil {
369525
err = errors.Join(err, fmt.Errorf("close archive entry: %w", closeErr))
370526
}
@@ -387,13 +543,26 @@ func makeTempOutputPath(outputName string) (string, error) {
387543
return tmpName, nil
388544
}
389545

546+
type processBookOptions struct {
547+
OutputFile string
548+
}
549+
390550
// processBook processes single FB2 file. "src" is part of the source path
391551
// (always including file name) relative to the original path. When actual file
392552
// was specified it will be just base file name without a path. When looking
393553
// inside archive or directory it will be relative path inside archive or
394554
// directory (including base file name). "dst" is the destination directory
395-
// where the converted file should be written.
396-
func processBook(ctx context.Context, r io.Reader, src string, dst string, format common.OutputFmt, log *zap.Logger) (rerr error) {
555+
// where the converted file should be written. When outputFile is provided, it
556+
// is used as exact final output path instead of deriving a name from dst.
557+
func processBook(
558+
ctx context.Context,
559+
r io.Reader,
560+
src string,
561+
dst string,
562+
format common.OutputFmt,
563+
log *zap.Logger,
564+
opts processBookOptions,
565+
) (rerr error) {
397566
env := state.EnvFromContext(ctx)
398567

399568
var refID, outputName string
@@ -437,6 +606,9 @@ func processBook(ctx context.Context, r io.Reader, src string, dst string, forma
437606

438607
// Determine output file name and path based on input and configuration.
439608
outputName = buildOutputPath(c, src, dst, env)
609+
if opts.OutputFile != "" {
610+
outputName = opts.OutputFile
611+
}
440612

441613
// Check if output file already exists, but do not remove it until a new
442614
// output has been generated successfully.

0 commit comments

Comments
 (0)