use
5.016;
our
$VERSION
=
'0.02'
;
my
$PRGNAM
=
'gutenberg'
;
my
$PRGVER
=
$VERSION
;
my
$HELP
=
<<"HERE";
$PRGNAM - $PRGVER
Usage: $0 [options] command [command options] [args]
Consult the $PRGNAM manual for more information.
HERE
my
$VER_MSG
=
<<"HERE";
$PRGNAM - $PRGVER
Copyright (C) 2025 Samuel Young
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
HERE
my
%COMMANDS
= (
'update'
=> \
&update
,
'search'
=> \
&search
,
'get'
=> \
&get
,
'meta'
=> \
&meta
,
);
sub
_default_data { File::Spec->catfile(home,
'.gutenberg'
) }
sub
_prompt {
my
$prompt
=
shift
;
while
(1) {
print
"$prompt [y/N] "
;
my
$in
=
readline
STDIN;
chomp
$in
;
if
(fc
$in
eq fc
'y'
) {
return
1;
}
elsif
(!
$in
or fc
$in
eq fc
'n'
) {
return
0;
}
else
{
warn
"'$in' is an invalid reponse\n"
;
}
}
}
sub
_nprompt {
my
$prompt
=
shift
;
my
%n
=
map
{
$_
=> 1 }
@_
;
while
(1) {
print
"$prompt "
;
my
$in
=
readline
STDIN;
chomp
$in
;
if
(!
$in
or fc
$in
eq fc
'n'
) {
return
undef
;
}
elsif
(
$in
=~ /^\d+$/ and
exists
$n
{
$in
}) {
return
$in
;
}
else
{
warn
"'$in' is an invalid reponse\n"
;
}
}
}
sub
_title2rx {
my
$title
=
shift
;
my
$rx
;
if
(
$title
=~ /^\/(.*)\/$/) {
$rx
=
qr/$1/
i;
}
else
{
$rx
=
qr/\Q$title\E/
i;
}
return
$rx
;
}
sub
_print_book {
my
$book
=
shift
;
my
$out
=
shift
//
*STDOUT
;
print
{
$out
}
<<"HERE";
ID: $book->{ 'Text#' }
Title: $book->{ Title }
Type: $book->{ Type }
Issued: $book->{ Issued }
Authors: $book->{ Authors }
Language: $book->{ Language }
Subjects: $book->{ Subjects }
Shelves: $book->{ Bookshelves }
LoCC: $book->{ LoCC }
HERE
}
sub
_print_book_json {
my
$book
=
shift
;
my
$out
=
shift
//
*STDOUT
;
my
$copy
;
%$copy
=
%$book
;
for
my
$k
(
qw(Authors Subjects Bookshelves LoCC)
) {
$copy
->{
$k
} = [
split
/\s*;\s*/,
$copy
->{
$k
} ];
}
my
$json
= JSON::PP->new->pretty(1)->canonical(1);
print
{
$out
}
$json
->encode(
$copy
);
}
sub
_touch_get {
my
$self
=
shift
;
if
(-f
$self
->{ GetFile }) {
utime
undef
,
undef
,
$self
->{ GetFile };
}
else
{
my
$fh
;
open
$fh
,
'>'
,
$self
->{ GetFile } and
close
$fh
or
die
"Failed to open $self->{ GetFile } for writing: $!\n"
;
}
return
1;
}
sub
_get_ok {
my
$self
=
shift
;
return
time
- ((
stat
(
$self
->{ GetFile }))[9] // 0) > 5;
}
sub
_search {
my
$self
=
shift
;
my
$targ
=
shift
;
my
$have_params
=
defined
$targ
;
my
$catalog
= EBook::Gutenberg::Catalog->new(
$self
->{ Catalog });
my
$filter
= {
Type
=>
sub
{
$_
eq
'Text'
},
};
if
(@{
$self
->{ Authors } }) {
my
@words
=
map
{
split
/\s/ }
map
{ s/\W+/ /gr }
@{
$self
->{ Authors } };
$filter
->{ Authors } =
sub
{
my
$a
=
$_
;
all {
$a
=~ m/(^|\W)\Q
$_
\E(\W|$)/i }
@words
;
};
$have_params
= 1;
}
if
(@{
$self
->{ Subjects } }) {
my
@words
=
map
{
split
/\s/ }
map
{ s/\W+/ /gr }
@{
$self
->{ Subjects } };
$filter
->{ Subjects } =
sub
{
my
$a
=
$_
;
all {
$a
=~ m/(^|\W)\Q
$_
\E(\W|$)/i }
@words
;
};
$have_params
= 1;
}
if
(@{
$self
->{ Shelves } }) {
my
@words
=
map
{
split
/\s/ }
map
{ s/\W+/ /gr }
@{
$self
->{ Shelves } };
$filter
->{ Bookshelves } =
sub
{
my
$a
=
$_
;
all {
$a
=~ m/(^|\w)\Q
$_
\E(\W|$)/i }
@words
;
};
$have_params
= 1;
}
if
(
defined
$self
->{ Language }) {
$filter
->{ Language } =
sub
{
$_
eq
$self
->{ Language } };
$have_params
= 1;
}
unless
(
$have_params
) {
$self
->help(1);
}
my
@books
;
if
(not
defined
$targ
) {
@books
= @{
$catalog
->books(
$filter
) };
}
elsif
(
$targ
=~ /^\d+$/) {
my
$book
=
$catalog
->book(
$targ
);
unless
(
defined
$book
) {
die
"Could not find an ebook with an ID of $targ\n"
;
}
@books
= (
$book
);
}
else
{
my
$rx
= _title2rx(
$targ
);
$filter
->{ Title } =
sub
{
$_
=~
$rx
};
@books
= @{
$catalog
->books(
$filter
) };
}
return
@books
;
}
sub
_print_list {
my
@books
=
@_
;
my
$idlen
= max (
length
(
'ID'
),
map
{
length
$_
->{
'Text#'
} }
@books
);
printf
"%-*s %s\n"
,
$idlen
,
'ID'
,
'Title'
;
printf
"%s\n"
,
'-'
x 25;
for
my
$b
(
@books
) {
printf
"%-*s %s\n"
,
$idlen
,
$b
->{
'Text#'
},
$b
->{ Title };
}
}
sub
help {
my
$self
=
shift
;
my
$exit
=
shift
;
local
$SIG
{ __WARN__ } =
sub
{
my
$ign
=
$_
[0] };
my
$error
;
unless
($^O eq
'os2'
) {
open
$error
,
'>&'
, \
*STDERR
;
open
STDERR,
'>'
, File::Spec->devnull;
}
system
'perldoc'
, $0;
unless
($? >> 8 == 0) {
print
$HELP
;
}
unless
($^O eq
'os2'
) {
close
STDERR;
open
STDERR,
'>&'
,
$error
;
}
exit
$exit
if
defined
$exit
;
return
1;
}
sub
update {
my
$self
=
shift
;
my
$catalog
= EBook::Gutenberg::Catalog->new(
$self
->{ Catalog });
unless
(
$self
->_get_ok) {
die
"Please wait at least 5 seconds before performing another "
.
"network operation with Project Gutenberg\n"
;
}
unless
(
$self
->{ Quiet }) {
say
"Fetching Project Gutenberg catalog, please be patient"
;
}
$catalog
->fetch;
$self
->_touch_get;
unless
(
$self
->{ Quiet }) {
say
"Updated $self->{ Catalog }"
;
}
return
1;
}
sub
search {
my
$self
=
shift
;
unless
(-f
$self
->{ Catalog }) {
die
"Could not find an existing Project Gutenberg catalog, please "
.
"run 'update' to fetch a catalog before running 'search'\n"
;
}
my
@books
=
$self
->_search(
shift
@{
$self
->{ Args } });
if
(
@books
== 0) {
die
"Could not find any ebooks matching the given parameters\n"
;
}
else
{
_print_list(
@books
);
}
return
1;
}
sub
get {
my
$self
=
shift
;
unless
(-f
$self
->{ Catalog }) {
die
"Could not find an existing Project Gutenberg catalog, please "
.
"run 'update' to fetch a catalog before running 'get'\n"
;
}
unless
(
$self
->_get_ok) {
die
"Please wait at least 5 seconds before performing another "
.
"network operation with Project Gutenberg\n"
;
}
my
@books
=
$self
->_search(
shift
@{
$self
->{ Args } });
my
$sel
;
if
(
@books
== 0) {
die
"Could not find any ebooks matching the given parameters\n"
;
}
elsif
(
@books
== 1 or
$self
->{ NoPrompt }) {
$sel
=
$books
[0];
}
else
{
my
%nmap
=
map
{
$books
[
$_
]->{
'Text#'
} =>
$_
} 0 ..
$#books
;
_print_list(
@books
);
if
(
@books
>= 100 and !
$self
->{ Quiet }) {
say
"You might consider narrowing your search parameters"
;
}
my
$n
= _nprompt(
"Please select an ebook ID:"
,
keys
%nmap
);
unless
(
defined
$n
) {
say
"Doing nothing"
unless
$self
->{ Quiet };
return
1;
}
$sel
=
$books
[
$nmap
{
$n
}];
}
unless
(
$sel
->{ Type } eq
'Text'
) {
die
"gutenberg does not currently support fetching non-text ebooks\n"
;
}
my
$ok
=
$self
->{ NoPrompt } ? 1 :
do
{
_print_book(
$sel
);
_prompt(
"Would you like to download this ebook?"
);
};
unless
(
$ok
) {
say
"Doing nothing"
unless
$self
->{ Quiet };
return
1;
}
my
$link
= gutenberg_link(
$sel
->{
'Text#'
},
$self
->{ Format });
unless
(
$self
->{ Quiet }) {
say
"Fetching $link, please be patient."
;
}
my
$fetch
= gutenberg_get(
$sel
->{
'Text#'
},
{
fmt
=>
$self
->{ Format },
to
=>
$self
->{ To },
}
);
$self
->_touch_get;
unless
(
$self
->{ Quiet }) {
say
"Downloaded ebook to $fetch"
;
}
return
1;
}
sub
meta {
my
$self
=
shift
;
unless
(-f
$self
->{ Catalog }) {
die
"Could not find an existing Project Gutenberg catalog, please "
.
"run 'update' to fetch a catalog before running 'meta'\n"
;
}
my
$id
=
shift
@{
$self
->{ Args } }
or
$self
->help(1);
unless
(
$id
=~ /^\d+$/) {
die
"'meta' must be given an ebook ID as argument\n"
;
}
my
$catalog
= EBook::Gutenberg::Catalog->new(
$self
->{ Catalog });
my
$book
=
$catalog
->book(
$id
);
unless
(
defined
$book
) {
die
"Could not find an ebook with an ID of $id\n"
;
}
if
(
$self
->{ MetaJSON }) {
_print_book_json(
$book
);
}
else
{
_print_book(
$book
);
}
return
1;
}
sub
init {
my
$class
=
shift
;
my
$self
= {
Command
=>
undef
,
Data
=>
undef
,
To
=>
undef
,
Format
=>
undef
,
Authors
=> [],
Subjects
=> [],
Language
=>
undef
,
Shelves
=> [],
NoPrompt
=> 0,
Quiet
=> 0,
MetaJSON
=> 0,
Args
=> [],
Catalog
=>
undef
,
GetFile
=>
undef
,
};
bless
$self
,
$class
;
Getopt::Long::config(
'bundling'
);
GetOptions(
'data|d=s'
=> \
$self
->{ Data },
'to|t=s'
=> \
$self
->{ To },
'format|f=s'
=> \
$self
->{ Format },
'author|a=s'
=>
$self
->{ Authors },
'subject|s=s'
=>
$self
->{ Subjects },
'language|l=s'
=> \
$self
->{ Language },
'shelf|H=s'
=>
$self
->{ Shelves },
'no-prompt|y'
=> \
$self
->{ NoPrompt },
'quiet|q'
=> \
$self
->{ Quiet },
'json|j'
=> \
$self
->{ MetaJSON },
'help|h'
=>
sub
{
$self
->help(0); },
'version|v'
=>
sub
{
print
$VER_MSG
;
exit
0; },
) or
die
"Invalid command line arguments\n"
;
$self
->{ Command } =
shift
@ARGV
or
$self
->help(1);
$self
->{ Args } = [
@ARGV
];
unless
(
exists
$COMMANDS
{
$self
->{ Command } }) {
die
"'$self->{ Command }' is not a valid command\n"
;
}
$self
->{ Data } //=
$ENV
{ GUTENBERG_DATA };
$self
->{ Data } //= _default_data;
unless
(-d
$self
->{ Data }) {
make_path(
$self
->{ Data });
}
$self
->{ Catalog } = File::Spec->catfile(
$self
->{ Data },
'pg_catalog.csv'
);
$self
->{ GetFile } = File::Spec->catfile(
$self
->{ Data },
'get'
);
$self
->{ Format } //=
'epub3'
;
$self
->{ Format } =
lc
$self
->{ Format };
unless
(
exists
$EBook::Gutenberg::Get::FORMATS
{
$self
->{ Format } }) {
die
"'$self->{ Format }' is not a valid ebook format\n"
;
}
if
(
defined
$self
->{ Language }) {
unless
(
length
$self
->{ Language } == 2) {
die
"-l|--language takes a two-character language code as argument\n"
;
}
$self
->{ Language } =
lc
$self
->{ Language };
}
binmode
*STDOUT
,
':utf8'
;
return
$self
;
}
sub
run {
my
$self
=
shift
;
$COMMANDS
{
$self
->{ Command } }(
$self
);
return
1;
}
1;