@@ -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.
226382func 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