File Coverage

blib/lib/EBook/Gutenberg.pm
Criterion Covered Total %
statement 175 399 43.8
branch 43 166 25.9
condition 9 61 14.7
subroutine 32 50 64.0
pod 8 8 100.0
total 267 684 39.0


line stmt bran cond sub pod time code
1             package EBook::Gutenberg;
2 2     2   564713 use 5.016;
  2         7  
3             our $VERSION = '1.00';
4 2     2   11 use strict;
  2         5  
  2         47  
5 2     2   31 use warnings;
  2         5  
  2         129  
6              
7 2     2   1565 use Getopt::Long;
  2         30932  
  2         16  
8 2     2   440 use File::Path qw(make_path);
  2         4  
  2         159  
9 2     2   14 use File::Spec;
  2         5  
  2         68  
10 2     2   2282 use File::Temp qw(tempfile);
  2         25728  
  2         205  
11 2     2   900 use JSON::PP;
  2         23231  
  2         217  
12 2     2   20 use List::Util qw(all first max);
  2         4  
  2         170  
13              
14 2     2   1359 use EBook::Gutenberg::Catalog;
  2         8  
  2         97  
15 2     2   1249 use EBook::Gutenberg::Dialog qw(:codes);
  2         7  
  2         398  
16 2     2   1188 use EBook::Gutenberg::Get;
  2         7  
  2         127  
17 2     2   922 use EBook::Gutenberg::Home;
  2         6  
  2         12075  
18              
19             my $PRGNAM = 'gutenberg';
20             my $PRGVER = $VERSION;
21              
22             my $HELP = <<"HERE";
23             $PRGNAM - $PRGVER
24              
25             Usage: $0 [options] command [command options] [args]
26              
27             Commands:
28             update Update local Project Gutenberg catalog
29             get Download ebook matching target
30             search Search for ebooks matching target
31             meta Dump ebook metadata
32             menu Launch gutenberg ncurses interface
33              
34             Options:
35             -d|--data= gutenberg data directory
36             -y|--no-prompt Disable prompts for user input
37             -q|--quiet Disable informative output
38             -h|--help Print this help message
39             -v|--version Print gutenberg version
40              
41             Consult the gutenberg(1) manual for documentation on command-specific options.
42             HERE
43              
44             my $VER_MSG = <<"HERE";
45             $PRGNAM - $PRGVER
46              
47             Copyright (C) 2025 Samuel Young
48              
49             This program is free software: you can redistribute it and/or modify
50             it under the terms of the GNU General Public License as published by
51             the Free Software Foundation, either version 3 of the License, or
52             (at your option) any later version.
53             HERE
54              
55             my %COMMANDS = (
56             'update' => \&update,
57             'search' => \&search,
58             'get' => \&get,
59             'meta' => \&meta,
60             'menu' => \&menu,
61             );
62              
63             my $OLD_DEFAULT_DATA = File::Spec->catfile(home, '.gutenberg');
64             my $DOT_LOCAL = File::Spec->catfile(home, '.local/share');
65              
66             my $ARG_MAX = 4096;
67              
68             sub _default_data {
69              
70             # The default prior to 0.03
71 0 0   0   0 if (-d $OLD_DEFAULT_DATA) {
72 0         0 return $OLD_DEFAULT_DATA;
73             }
74              
75 0 0 0     0 if (exists $ENV{ XDG_DATA_HOME } and -d $ENV{ XDG_DATA_HOME }) {
76 0         0 return File::Spec->catfile($ENV{ XDG_DATA_HOME }, 'gutenberg');
77             }
78              
79 0 0       0 if (-d $DOT_LOCAL) {
80 0         0 return File::Spec->catfile($DOT_LOCAL, 'gutenberg');
81             }
82              
83 0         0 return $OLD_DEFAULT_DATA;
84              
85             }
86              
87             sub _prompt {
88              
89 0     0   0 my $prompt = shift;
90              
91 0         0 while (1) {
92 0         0 print "$prompt [y/N] ";
93 0         0 my $in = readline STDIN;
94 0         0 chomp $in;
95 0 0 0     0 if (fc $in eq fc 'y') {
    0          
96 0         0 return 1;
97             } elsif ($in eq '' or fc $in eq fc 'n') {
98 0         0 return 0;
99             } else {
100 0         0 warn "'$in' is an invalid reponse\n";
101             }
102             }
103              
104             }
105              
106             # Ask user to select a number out of a given list valid numbers
107             sub _nprompt {
108              
109 0     0   0 my $prompt = shift;
110 0         0 my %n = map { $_ => 1 } @_;
  0         0  
111              
112 0         0 while (1) {
113 0         0 print "$prompt ";
114 0         0 my $in = readline STDIN;
115 0         0 chomp $in;
116 0 0 0     0 if ($in eq '' or fc $in eq fc 'n') {
    0 0        
117 0         0 return undef;
118             } elsif ($in =~ /^\d+$/ and exists $n{ $in }) {
119 0         0 return $in;
120             } else {
121 0         0 warn "'$in' is an invalid reponse\n";
122             }
123             }
124              
125             }
126              
127             sub _title2rx {
128              
129 2     2   3 my $title = shift;
130              
131 2         4 my $rx;
132              
133 2 100       10 if ($title =~ /^\/(.*)\/$/) {
134 1         13 $rx = qr/$1/i;
135             # treat as literal string
136             } else {
137 1         21 $rx = qr/\Q$title\E/i;
138             }
139              
140 2         8 return $rx;
141              
142             }
143              
144             sub _target2param {
145              
146 3     3   6 my $targ = shift;
147              
148 3 100       17 if ($targ =~ /^\d+$/) {
149 1         4 return id => $targ;
150             } else {
151 2         6 return title => _title2rx($targ);
152             }
153              
154             }
155              
156             sub _book_meta_str {
157              
158 20     20   35 my $book = shift;
159              
160 20         349 return <<"HERE";
161             ID: $book->{ 'Text#' }
162             Title: $book->{ Title }
163             Type: $book->{ Type }
164             Issued: $book->{ Issued }
165             Authors: $book->{ Authors }
166             Language: $book->{ Language }
167             Subjects: $book->{ Subjects }
168             Shelves: $book->{ Bookshelves }
169             LoCC: $book->{ LoCC }
170             HERE
171              
172             }
173              
174             sub _book_meta_json {
175              
176 20     20   32 my $book = shift;
177              
178 20         140 my %copy = %$book;;
179              
180 20         79 for my $k (qw(Authors Subjects Bookshelves LoCC)) {
181 80         702 $copy{ $k } = [ split /\s*;\s*/, $copy{ $k } ];
182             }
183              
184 20         190 my $json = JSON::PP->new->pretty(1)->canonical(1);
185              
186 20         2512 return $json->encode(\%copy);
187              
188             }
189              
190             sub _touch_get {
191              
192 0     0   0 my $self = shift;
193              
194             # touch file
195 0 0       0 if (-f $self->{ GetFile }) {
196 0         0 utime undef, undef, $self->{ GetFile };
197             # create file if it doesn't exist
198             } else {
199 0         0 my $fh;
200 0 0 0     0 open $fh, '>', $self->{ GetFile } and close $fh
201             or die "Failed to open $self->{ GetFile } for writing: $!\n";
202             }
203              
204 0         0 return 1;
205              
206             }
207              
208             # Wait at least 5 seconds between multiple Project Gutenberg network
209             # operations (update, get)
210             sub _get_ok {
211              
212 0     0   0 my $self = shift;
213              
214 0   0     0 return time - ((stat($self->{ GetFile }))[9] // 0) > 5;
215              
216             }
217              
218             sub _gen_search_params {
219              
220 7     7   10 my $self = shift;
221              
222 7         10 my %search;
223              
224 7 100       9 if (@{ $self->{ Args } }) {
  7         41  
225 3         12 my ($k, $v) = _target2param($self->{ Args }->[0]);
226 3         11 $search{ $k } = $v;
227             }
228              
229 7 100       46 if (@{ $self->{ Authors } }) {
  7         20  
230 1         4 $search{ authors } = $self->{ Authors };
231             }
232              
233 7 100       9 if (@{ $self->{ Subjects } }) {
  7         16  
234 1         2 $search{ subjects } = $self->{ Subjects };
235             }
236              
237 7 100       17 if (defined $self->{ Language }) {
238 1         3 $search{ language } = $self->{ Language };
239             }
240              
241 7 100       10 if (@{ $self->{ Shelves } }) {
  7         14  
242 1         4 $search{ shelves } = $self->{ Shelves };
243             }
244              
245 7         28 return %search;
246              
247             }
248              
249             sub _search {
250              
251 7     7   9 my $self = shift;
252 7         15 my %params = @_;
253              
254 7         62 my $catalog = EBook::Gutenberg::Catalog->new($self->{ Catalog });
255              
256             my $filter = {
257 121     121   5621 Type => sub { $_ eq 'Text' },
258 7         38 };
259              
260 7 100       17 if (defined $params{ title }) {
261 2     40   6 $filter->{ Title } = sub { m/$params{ title }/i };
  40         464  
262             }
263              
264 7 100       17 if (defined $params{ id }) {
265 1     20   6 $filter->{ 'Text#' } = sub { $_ == $params{ id } };
  20         1686  
266             }
267              
268             # Get a list words from each supplied author parameter, with non-word
269             # characters stripped out. Then filter out books that do not contain every
270             # word from that list in their author entries. This seems to be the simplest
271             # and DWIMest way of going about this that I could find.
272 7 100 66     19 if (defined $params{ authors } and @{ $params{ authors } }) {
  1         5  
273             my @words =
274 1         5 map { split /\s+/ }
275 1         9 map { s/\W+/ /gr }
276 1         3 @{ $params{ authors } };
  1         4  
277             $filter->{ Authors } = sub {
278 20     20   238 my $a = $_;
279 20         77 all { $a =~ m/(^|\W)\Q$_\E(\W|$)/i } @words;
  21         322  
280 1         6 };
281             }
282              
283             # Same as authors
284 7 100 66     16 if (defined $params{ subjects } and @{ $params{ subjects } }) {
  1         3  
285             my @words =
286 1         3 map { split /\s+/ }
287 1         6 map { s/\W+/ /gr }
288 1         3 @{ $params{ subjects } };
  1         2  
289             $filter->{ Subjects } = sub {
290 20     20   240 my $a = $_;
291 20         80 all { $a =~ m/(^|\W)\Q$_\E(\W|$)/i } @words;
  28         683  
292 1         3 };
293             }
294              
295             # Same as authors
296 7 100 66     19 if (defined $params{ shelves } and @{ $params{ shelves } }) {
  1         6  
297             my @words =
298 1         6 map { split /\s+/ }
299 1         36 map { s/\W+/ /gr }
300 1         3 @{ $params{ shelves } };
  1         3  
301             $filter->{ Bookshelves } = sub {
302 20     20   181 my $a = $_;
303 20         46 all { $a =~ m/(^|\W)\Q$_\E(\W|$)/i } @words;
  20         304  
304 1         8 };
305             }
306              
307 7 100       21 if (defined $params{ language }) {
308 1     20   3 $filter->{ Language } = sub { $_ eq $params{ language } };
  20         171  
309             }
310              
311 7         9 my @books = @{ $catalog->books($filter) };
  7         28  
312              
313 7         47 return @books;
314              
315             }
316              
317             sub _print_list {
318              
319 7     7   14 my @books = @_;
320              
321             # ugly :-(
322 7         11 my $idlen = max (length('ID'), map { length $_->{ 'Text#' } } @books);
  46         77  
323 7         181 printf "%-*s %s\n", $idlen, 'ID', 'Title';
324 7         39 printf "%s\n", '-' x 25;
325 7         15 for my $b (@books) {
326 46         163 printf "%-*s %s\n", $idlen, $b->{ 'Text#' }, $b->{ Title };
327             }
328              
329             }
330              
331             sub _dialog_search {
332              
333 0     0   0 my $self = shift;
334              
335 0         0 state $ltitl = '';
336 0         0 state $lauth = '';
337 0         0 state $lsubj = '';
338 0         0 state $llang = '';
339 0         0 state $lshlf = '';
340 0         0 state $lebid = '';
341              
342 0         0 while (1) {
343              
344             my ($rv, $form) = $self->{ Dialog }->form(
345             <<'HERE',
346             Search for an ebook using the parameter fields below. Fields left blank will be
347             ignored. The "Title" field can be given a Perl regex if the input starts and
348             ends with a slash (/) character.
349             HERE
350 0         0 17, 45, 6,
351             'Title', 1, 0, $ltitl, 1, 10, 36, 255,
352             'Author', 2, 0, $lauth, 2, 10, 36, 255,
353             'Subject', 3, 0, $lsubj, 3, 10, 36, 255,
354             'Language', 4, 0, $llang, 4, 10, 36, 255,
355             'Shelf', 5, 0, $lshlf, 5, 10, 36, 255,
356             'ID', 6, 0, $lebid, 6, 10, 36, 255,
357             {
358             title => 'Search',
359             ok_label => 'Search',
360             extra_button => 1,
361             extra_label => 'Update',
362             erase_on_exit => 1,
363             },
364             );
365              
366 0 0 0     0 last if $rv == DIALOG_CANCEL or $rv == DIALOG_ESC;
367              
368 0         0 $self->{ Dialog }->infobox('Searching...', 0, 0);
369              
370 0         0 ($ltitl, $lauth, $lsubj, $llang, $lshlf, $lebid) = @$form;
371              
372 0 0       0 if ($rv == DIALOG_EXTRA) {
    0          
373             $rv = $self->{ Dialog }->yesno(
374 0         0 'Would you like to update your local gutenberg catalog?', 0, 0
375             );
376 0 0       0 next if $rv != DIALOG_OK;
377 0         0 $self->{ Dialog }->infobox('Updating gutenberg catalog...', 0, 0);
378 0         0 my $catalog = EBook::Gutenberg::Catalog->new($self->{ Catalog });
379 0 0       0 sleep 5 unless $self->_get_ok;
380 0         0 $self->_touch_get;
381 0         0 eval { $catalog->fetch };
  0         0  
382 0 0       0 if ($@ ne '') {
383 0         0 $self->{ Dialog }->msgbox("Failed to fetch catalog: $@", 0, 0);
384 0         0 next;
385             }
386             $self->{ Dialog }->msgbox(
387 0         0 "Successfully updated gutenberg catalog", 0, 0
388             );
389             } elsif ($rv == DIALOG_OK) {
390              
391 0 0       0 unless (-s $self->{ Catalog }) {
392             $self->{ Dialog }->msgbox(
393 0         0 <<'HERE', 0, 0
394             No existing Project Gutenberg catalog found. Please run a catalog update before
395             searching.
396             HERE
397             );
398 0         0 next;
399             }
400              
401 0         0 my %param;
402              
403 0 0       0 if ($ltitl ne '') {
404 0         0 $param{ title } = _title2rx($ltitl);
405             }
406              
407 0 0       0 if ($lauth ne '') {
408 0         0 $param{ authors } = [ $lauth ];
409             }
410              
411 0 0       0 if ($lsubj ne '') {
412 0         0 $param{ subjects } = [ $lsubj ];
413             }
414              
415 0 0       0 if ($llang ne '') {
416 0         0 $param{ language } = $llang;
417             }
418              
419 0 0       0 if ($lshlf ne '') {
420 0         0 $param{ shelves } = [ $lshlf ];
421             }
422              
423 0 0       0 if ($lebid ne '') {
424 0 0       0 unless ($lebid =~ /^\d+$/) {
425             $self->{ Dialog }->msgbox(
426 0         0 "'ID' must be an integar", 0, 0
427             );
428 0         0 next;
429             }
430 0         0 $param{ id } = $lebid;
431             }
432              
433 0 0       0 unless (%param) {
434             $self->{ Dialog }->msgbox(
435 0         0 'No search parameters provided.', 0, 0
436             );
437 0         0 next;
438             }
439              
440 0         0 my @books = $self->_search(%param);
441              
442 0 0       0 if (@books > $ARG_MAX) {
    0          
443             $self->{ Dialog }->msgbox(
444 0         0 'Too many search results. Please narrow your search parameters.',
445             0, 0
446             );
447 0         0 next;
448             } elsif (@books == 0) {
449             $self->{ Dialog }->msgbox(
450 0         0 'Found no ebooks matching the given search parameters.', 0, 0
451             );
452             } else {
453 0         0 $self->_dialog_download_select(\@books);
454             }
455             }
456             }
457              
458 0         0 return 1;
459              
460             }
461              
462             sub _dialog_download_select {
463              
464 0     0   0 my $self = shift;
465 0         0 my $books = shift;
466              
467 0         0 my @iter = map { $_->{ 'Text#' } => $_->{ Title } } @$books;
  0         0  
468              
469 0         0 while (1) {
470              
471             my ($rv, $sel) = $self->{ Dialog }->menu(
472 0         0 '', 0, 0, 0,
473             @iter,
474             {
475             title => 'Search Results',
476             ok_label => 'View',
477             },
478             );
479              
480 0 0 0     0 last if $rv == DIALOG_CANCEL or $rv == DIALOG_ESC;
481              
482 0     0   0 my $book = first { $_->{ 'Text#' } == $sel } @$books;
  0         0  
483              
484 0         0 $self->_dialog_ebook($book);
485              
486             }
487              
488 0         0 return 1;
489              
490             }
491              
492             sub _dialog_ebook {
493              
494 0     0   0 my $self = shift;
495 0         0 my $book = shift;
496              
497 0         0 while (1) {
498              
499             my $rv = $self->{ Dialog }->yesno(
500             _book_meta_str($book), 0, 0,
501             {
502             title => $book->{ Title },
503 0         0 yes_label => 'Download',
504             extra_button => 1,
505             extra_label => 'Read',
506             no_label => 'Cancel',
507             },
508             );
509              
510 0 0 0     0 if ($rv == DIALOG_OK) {
    0          
    0          
511 0         0 $self->_dialog_download_format($book);
512             } elsif ($rv == DIALOG_EXTRA) {
513 0         0 $self->_dialog_read($book);
514             } elsif ($rv == DIALOG_CANCEL or $rv == DIALOG_ESC) {
515 0         0 last;
516             }
517              
518             }
519              
520 0         0 return 1;
521              
522             }
523              
524             sub _dialog_download_format {
525              
526 0     0   0 my $self = shift;
527 0         0 my $book = shift;
528              
529 0         0 while (1) {
530              
531             my ($rv, $sel) = $self->{ Dialog }->menu(
532 0         0 '', 0, 0, 0,
533             'epub3', 'Newer style EPUB. May not be compatible with older e-readers.',
534             'epub', 'Older style EPUB. Better compatibility with older e-readers.',
535             'epub-noimages', 'Same as "epub" but with no images.',
536             'kindle', 'KF8 ebook, an Amazon-proprietary format used by newer Kindle devices.',
537             'mobi', 'Ebook format commonly used by older Kindle devices.',
538             'text', 'Plain text file.',
539             'zip', 'Zip archive of HTML and images.',
540             'html', 'Single HTML page.',
541             {
542             title => 'Format Selection',
543             },
544             );
545              
546 0 0 0     0 last if $rv == DIALOG_CANCEL or $rv == DIALOG_ESC;
547              
548 0         0 $self->_dialog_download($book, $sel);
549              
550             }
551              
552             }
553              
554             sub _dialog_download {
555              
556 0     0   0 my $self = shift;
557 0         0 my $book = shift;
558 0         0 my $fmt = shift;
559              
560             my $default = sprintf "%s.%s",
561             $book->{ Title },
562 0         0 $EBook::Gutenberg::Get::FORMATS{ $fmt }->{ suffix };
563              
564              
565             my ($rv, $path) = $self->{ Dialog }->inputbox(
566 0         0 'Please input the path to write the downloaded ebook to.', 0, 0,
567             $default,
568             );
569              
570 0 0 0     0 return 1 if $rv == DIALOG_CANCEL or $rv == DIALOG_ESC;
571              
572 0         0 $self->{ Dialog }->infobox('Fetching ebook...', 0, 0);
573              
574 0         0 my $p = eval {
575 0 0       0 sleep 5 unless $self->_get_ok;
576 0         0 $self->_touch_get;
577             gutenberg_get(
578 0         0 $book->{ 'Text#' },
579             { fmt => $fmt, to => $path }
580             );
581             };
582              
583 0 0       0 if ($@ ne '') {
584             $self->{ Dialog }->msgbox(
585 0         0 "Failed to fetch ebook: $@", 0, 0
586             );
587 0         0 return 1;
588             }
589              
590             $self->{ Dialog }->msgbox(
591 0         0 "Successfully fetched $p", 0, 0
592             );
593              
594 0         0 return 1;
595              
596             }
597              
598             sub _dialog_read {
599              
600 0     0   0 my $self = shift;
601 0         0 my $book = shift;
602              
603             $self->{ Dialog }->infobox(
604 0         0 'Fetching text...', 0, 0
605             );
606              
607 0   0     0 my $file = $self->{ TxtCache }{ $book->{ 'Text#' } } // do {
608              
609 0         0 my $p = eval {
610 0         0 my $tmp = do {
611 0         0 my ($fh, $fn) = tempfile;
612 0         0 close $fh;
613 0         0 $fn;
614             };
615             # TODO: Sleep less?
616 0 0       0 sleep 5 unless $self->_get_ok;
617 0         0 $self->_touch_get;
618             gutenberg_get(
619 0         0 $book->{ 'Text#' },
620             { fmt => 'text', to => $tmp },
621             );
622             };
623              
624 0 0       0 if ($@ ne '') {
625             $self->{ Dialog }->msgbox(
626 0         0 "Failed to fetch ebook: $@", 0, 0
627             );
628 0         0 return 1;
629             }
630              
631 0         0 $self->{ TxtCache }{ $book->{ 'Text#' } } = $p;
632              
633             };
634              
635             # dialog does have a textbox widget, but it doesn't display large text files
636             # like Gutenberg's text ebooks correctly, so we'll just use a pager instead.
637 0         0 $self->{ Dialog }->pager($file);
638              
639 0         0 return 1;
640              
641             }
642              
643             sub help {
644              
645 0     0 1 0 my $self = shift;
646 0         0 my $exit = shift;
647              
648 0         0 print $HELP;
649              
650 0 0       0 exit $exit if defined $exit;
651              
652 0         0 return 1;
653              
654             }
655              
656             sub update {
657              
658 0     0 1 0 my $self = shift;
659              
660 0         0 my $catalog = EBook::Gutenberg::Catalog->new($self->{ Catalog });
661              
662 0 0       0 unless ($self->_get_ok) {
663 0         0 die "Please wait at least 5 seconds before performing another " .
664             "network operation with Project Gutenberg\n";
665             }
666              
667 0 0       0 unless ($self->{ Quiet }) {
668 0         0 say "Fetching Project Gutenberg catalog, please be patient";
669             }
670              
671 0         0 $catalog->fetch;
672              
673 0         0 $self->_touch_get;
674              
675 0 0       0 unless ($self->{ Quiet }) {
676 0         0 say "Updated $self->{ Catalog }";
677             }
678              
679 0         0 return 1;
680              
681             }
682              
683             sub search {
684              
685 7     7 1 10 my $self = shift;
686              
687 7 50       155 unless (-f $self->{ Catalog }) {
688 0         0 die "Could not find an existing Project Gutenberg catalog, please " .
689             "run 'update' to fetch a catalog before running 'search'\n";
690             }
691              
692 7         19 my %search = $self->_gen_search_params;
693              
694 7 50       17 unless (%search) {
695 0         0 $self->help(1);
696             }
697              
698 7         21 my @books = $self->_search(%search);
699              
700 7 50       16 if (@books == 0) {
701 0         0 die "Could not find any ebooks matching the given parameters\n";
702             } else {
703 7         16 _print_list(@books);
704             }
705              
706 7         60 return 1;
707              
708             }
709              
710             sub get {
711              
712 0     0 1 0 my $self = shift;
713              
714 0 0       0 unless (-f $self->{ Catalog }) {
715 0         0 die "Could not find an existing Project Gutenberg catalog, please " .
716             "run 'update' to fetch a catalog before running 'get'\n";
717             }
718              
719 0 0       0 unless ($self->_get_ok) {
720 0         0 die "Please wait at least 5 seconds before performing another " .
721             "network operation with Project Gutenberg\n";
722             }
723              
724 0         0 my %search = $self->_gen_search_params;
725              
726 0 0       0 unless (%search) {
727 0         0 $self->help(1);
728             }
729              
730 0         0 my @books = $self->_search(%search);
731              
732 0         0 my $sel;
733              
734 0 0 0     0 if (@books == 0) {
    0          
735 0         0 die "Could not find any ebooks matching the given parameters\n";
736             } elsif (@books == 1 or $self->{ NoPrompt }) {
737 0         0 $sel = $books[0];
738             } else {
739              
740 0         0 my %nmap = map { $books[$_]->{ 'Text#' } => $_ } 0 .. $#books;
  0         0  
741              
742 0         0 _print_list(@books);
743              
744 0 0 0     0 if (@books >= 100 and !$self->{ Quiet }) {
745 0         0 say "You might consider narrowing your search parameters";
746             }
747              
748 0         0 my $n = _nprompt("Please select an ebook ID:", keys %nmap);
749              
750 0 0       0 unless (defined $n) {
751 0 0       0 say "Doing nothing" unless $self->{ Quiet };
752 0         0 return 1;
753             }
754              
755 0         0 $sel = $books[$nmap{ $n }];
756              
757             }
758              
759 0 0       0 unless ($sel->{ Type } eq 'Text') {
760 0         0 die "gutenberg does not currently support fetching non-text ebooks\n";
761             }
762              
763 0 0       0 my $ok = $self->{ NoPrompt } ? 1 : do {
764 0         0 print _book_meta_str($sel);
765 0         0 _prompt("Would you like to download this ebook?");
766             };
767              
768 0 0       0 unless ($ok) {
769 0 0       0 say "Doing nothing" unless $self->{ Quiet };
770 0         0 return 1;
771             }
772              
773 0         0 my $link = gutenberg_link($sel->{ 'Text#' }, $self->{ Format });
774              
775 0 0       0 unless ($self->{ Quiet }) {
776 0         0 say "Fetching $link, please be patient.";
777             }
778              
779             my $fetch = gutenberg_get(
780             $sel->{ 'Text#' },
781             {
782             fmt => $self->{ Format },
783 0   0     0 to => $self->{ To } // "$sel->{ Title }.*",
784             }
785             );
786              
787 0         0 $self->_touch_get;
788              
789 0 0       0 unless ($self->{ Quiet }) {
790 0         0 say "Downloaded ebook to $fetch";
791             }
792              
793 0         0 return 1;
794              
795             }
796              
797             sub meta {
798              
799 40     40 1 60 my $self = shift;
800              
801 40 50       624 unless (-f $self->{ Catalog }) {
802 0         0 die "Could not find an existing Project Gutenberg catalog, please " .
803             "run 'update' to fetch a catalog before running 'meta'\n";
804             }
805              
806 40 50       68 my $id = shift @{ $self->{ Args } }
  40         138  
807             or $self->help(1);
808              
809 40 50       276 unless ($id =~ /^\d+$/) {
810 0         0 die "'meta' must be given an ebook ID as argument\n";
811             }
812              
813 40         339 my $catalog = EBook::Gutenberg::Catalog->new($self->{ Catalog });
814              
815 40         143 my $book = $catalog->book($id);
816              
817 40 50       120 unless (defined $book) {
818 0         0 die "Could not find an ebook with an ID of $id\n";
819             }
820              
821 40 100       108 if ($self->{ MetaJSON }) {
822 20         85 print _book_meta_json($book);
823             } else {
824 20         54 print _book_meta_str($book);
825             }
826              
827 40         15388 return 1;
828              
829             }
830              
831             sub menu {
832              
833 0     0 1 0 my $self = shift;
834              
835 0         0 my $dialog;
836 0         0 my ($rv, $sel);
837              
838 0         0 $self->{ Dialog } = EBook::Gutenberg::Dialog->new(
839             backtitle => "$PRGNAM $PRGVER"
840             );
841              
842 0         0 $self->_dialog_search;
843              
844 0         0 return 1;
845              
846             }
847              
848             sub init {
849              
850 47     47 1 254386 my $class = shift;
851              
852 47         458 my $self = {
853             Command => undef,
854             Data => undef,
855             To => undef,
856             Format => undef,
857             Authors => [],
858             Subjects => [],
859             Language => undef,
860             Shelves => [],
861             NoPrompt => 0,
862             Quiet => 0,
863             MetaJSON => 0,
864             Args => [],
865             # Not set by any option
866             Catalog => undef,
867             GetFile => undef,
868             Dialog => undef,
869             TxtCache => {},
870             };
871              
872 47         112 bless $self, $class;
873              
874 47         210 Getopt::Long::config('bundling');
875             GetOptions(
876             'data|d=s' => \$self->{ Data },
877             'to|t=s' => \$self->{ To },
878             'format|f=s' => \$self->{ Format },
879             'author|a=s' => $self->{ Authors },
880             'subject|s=s' => $self->{ Subjects },
881             'language|l=s' => \$self->{ Language },
882             'shelf|H=s' => $self->{ Shelves },
883             'no-prompt|y' => \$self->{ NoPrompt },
884             'quiet|q' => \$self->{ Quiet },
885             'json|j' => \$self->{ MetaJSON },
886 0     0   0 'help|h' => sub { $self->help(0); },
887 0     0   0 'version|v' => sub { print $VER_MSG; exit 0; },
  0         0  
888 47 50       1918 ) or die "Invalid command line arguments\n";
889              
890 47 50       63449 $self->{ Command } = shift @ARGV or $self->help(1);
891 47         142 $self->{ Args } = [ @ARGV ];
892              
893 47 50       174 unless (exists $COMMANDS{ $self->{ Command } }) {
894 0         0 die "'$self->{ Command }' is not a valid command\n";
895             }
896              
897 47   33     129 $self->{ Data } //= $ENV{ GUTENBERG_DATA };
898 47   33     99 $self->{ Data } //= _default_data;
899 47 50       1171 unless (-d $self->{ Data }) {
900 0         0 make_path($self->{ Data });
901             }
902             $self->{ Catalog } = File::Spec->catfile(
903             $self->{ Data },
904 47         789 'pg_catalog.csv'
905             );
906             # GetFile is used to keep track of the last time we fetched something from
907             # Project Gutenberg. gutenberg tries to wait at least 5 seconds between
908             # Project Gutenberg network operations.
909             $self->{ GetFile } = File::Spec->catfile(
910             $self->{ Data },
911 47         328 'get'
912             );
913              
914 47   50     270 $self->{ Format } //= 'epub3';
915 47         122 $self->{ Format } = lc $self->{ Format };
916 47 50       191 unless (exists $EBook::Gutenberg::Get::FORMATS{ $self->{ Format } }) {
917 0         0 die "'$self->{ Format }' is not a valid ebook format\n";
918             }
919              
920 47 100       114 if (defined $self->{ Language }) {
921 1 50       3 unless (length $self->{ Language } == 2) {
922 0         0 die "-l|--language takes a two-character language code as argument\n";
923             }
924 1         3 $self->{ Language } = lc $self->{ Language };
925             }
926              
927 47         259 binmode *STDOUT, ':utf8';
928              
929 47         305 return $self;
930              
931             }
932              
933             sub run {
934              
935 47     47 1 925 my $self = shift;
936              
937 47         162 $COMMANDS{ $self->{ Command } }($self);
938              
939 47         297 return 1;
940              
941             }
942              
943             DESTROY {
944              
945 47     47   1750 my $self = shift;
946              
947 47         73 for my $f (keys %{ $self->{ TxtCache } }) {
  47         565  
948 0 0         unlink $f if -e $f;
949             }
950              
951             }
952              
953             1;
954              
955             =head1 NAME
956              
957             EBook::Gutenberg - Fetch ebooks from Project Gutenberg
958              
959             =head1 SYNOPSIS
960              
961             use EBook::Gutenberg;
962              
963             my $gutenberg = EBook::Gutenberg->init;
964             $gutenberg->run;
965              
966             =head1 DESCRIPTION
967              
968             B is a module that provides the core functionality for the
969             L utility. This is developer documentation, for L user
970             documentation you should consult its manual.
971              
972             =head1 METHODS
973              
974             =over 4
975              
976             =item $gut = EBook::Gutenberg->init()
977              
978             Reads C<@ARGV> and returns a blessed C object.
979              
980             =item $gut->run()
981              
982             Runs L based on the parameters processed in C.
983              
984             =item $gut->update
985              
986             Update local Project Gutenberg catalog; the C command.
987              
988             =item $gut->search
989              
990             Search for ebooks; the C command.
991              
992             =item $gut->get
993              
994             Download an ebook; the C command.
995              
996             =item $gut->meta
997              
998             Print ebook metadata; the C command.
999              
1000             =item $gut->menu
1001              
1002             Launch the L-based menu interface.
1003              
1004             =item $gut->help([$exit])
1005              
1006             Print L manual. Exit with code C<$exit> if provided.
1007              
1008             =back
1009              
1010             =head1 AUTHOR
1011              
1012             Written by Samuel Young, Esamyoung12788@gmail.comE.
1013              
1014             This project's source can be found on its
1015             L. Comments and pull
1016             requests are welcome!
1017              
1018             =head1 COPYRIGHT
1019              
1020             Copyright (C) 2025 Samuel Young
1021              
1022             This program is free software: you can redistribute it and/or modify
1023             it under the terms of the GNU General Public License as published by
1024             the Free Software Foundation, either version 3 of the License, or
1025             (at your option) any later version.
1026              
1027             =head1 SEE ALSO
1028              
1029             L
1030              
1031             =cut
1032              
1033             # vim: expandtab shiftwidth=4