diff --git a/.gitignore b/.gitignore index 19db4255..f4c6ca40 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ www/manual/* config/php/fpm/standardebooks.org-secrets.ini www/bulk-downloads/months www/bulk-downloads/subjects +www/bulk-downloads/collections diff --git a/config/apache/standardebooks.org.conf b/config/apache/standardebooks.org.conf index 28663ff1..95e00c8c 100644 --- a/config/apache/standardebooks.org.conf +++ b/config/apache/standardebooks.org.conf @@ -303,7 +303,7 @@ Define webroot /standardebooks.org/web ( \ select Email, Uuid from Patrons p inner join Users u using (UserId) where p.Ended is null \ union \ - select Email, Uuid from FeedUsers fu inner join Users u using (UserId) where fu.Ended is null \ + select Email, Uuid from ApiKeys fu inner join Users u using (UserId) where fu.Ended is null \ ) x where %s in (Email, Uuid) limit 1 \ " diff --git a/config/apache/standardebooks.test.conf b/config/apache/standardebooks.test.conf index 4085714b..5ae91589 100644 --- a/config/apache/standardebooks.test.conf +++ b/config/apache/standardebooks.test.conf @@ -229,6 +229,7 @@ Define webroot /standardebooks.org/web RewriteRule ^/ebooks/([^\./]+?)$ /ebooks/author.php?url-path=$1 [QSA] RewriteRule ^/tags/([^\./]+?)$ /ebooks/index.php?tags[]=$1 [QSA] RewriteRule ^/collections/([^\./]+?)$ /ebooks/index.php?collection=$1 [QSA] + RewriteRule ^/collections/([^/]+?)/download$ /bulk-downloads/get.php?collection=$1 # Prevent this rule from firing if we're getting a distribution file RewriteCond %{REQUEST_FILENAME} !^/ebooks/.+?/downloads/.+$ @@ -285,7 +286,7 @@ Define webroot /standardebooks.org/web ( \ select Email, Uuid from Patrons p inner join Users u using (UserId) where p.Ended is null \ union \ - select Email, Uuid from FeedUsers fu inner join Users u using (UserId) where fu.Ended is null \ + select Email, Uuid from ApiKeys fu inner join Users u using (UserId) where fu.Ended is null \ ) x where %s in (Email, Uuid) limit 1 \ " diff --git a/config/sql/se/FeedUsers.sql b/config/sql/se/ApiKeys.sql similarity index 88% rename from config/sql/se/FeedUsers.sql rename to config/sql/se/ApiKeys.sql index 612bfbee..5c2d45f4 100644 --- a/config/sql/se/FeedUsers.sql +++ b/config/sql/se/ApiKeys.sql @@ -1,4 +1,4 @@ -CREATE TABLE `FeedUsers` ( +CREATE TABLE `ApiKeys` ( `UserId` int(10) unsigned NOT NULL, `Created` datetime NOT NULL, `Ended` datetime DEFAULT NULL, diff --git a/lib/Formatter.php b/lib/Formatter.php index 342d1373..a0b0696a 100644 --- a/lib/Formatter.php +++ b/lib/Formatter.php @@ -54,13 +54,13 @@ class Formatter{ $output = number_format($bytes / 1024, 0) . 'KB'; } elseif($bytes > 1){ - $output = $bytes . 'b'; + $output = $bytes . 'B'; } elseif($bytes == 1){ - $output = $bytes . 'b'; + $output = $bytes . 'B'; } else{ - $output = '0b'; + $output = '0B'; } return $output; diff --git a/lib/Library.php b/lib/Library.php index 65fc2ff3..b03f172a 100644 --- a/lib/Library.php +++ b/lib/Library.php @@ -225,6 +225,70 @@ class Library{ return $ebooks; } + private static function FillBulkDownloadObject(string $file, string $downloadType): stdClass{ + $obj = new stdClass(); + $obj->Size = Formatter::ToFileSize(filesize($file)); + $obj->Updated = new DateTime('@' . filemtime($file)); + + // The count of ebooks in each file is stored as a filesystem attribute + $obj->Count = exec('attr -g se-ebook-count ' . escapeshellarg($file)) ?: null; + if($obj->Count !== null){ + $obj->Count = intval($obj->Count); + } + + // The subject of the batch is stored as a filesystem attribute + $obj->Label = exec('attr -g se-label ' . escapeshellarg($file)) ?: null; + if($obj->Label === null){ + $obj->Label = str_replace('se-ebooks-', '', basename($file, '.zip')); + } + + $obj->UrlLabel = Formatter::MakeUrlSafe($obj->Label); + + $obj->Url = '/bulk-downloads/' . $downloadType . '/' . $obj->UrlLabel . '/' . basename($file); + + // The type of ebook in the zip is stored as a filesystem attribute + $obj->Type = exec('attr -g se-ebook-type ' . escapeshellarg($file)); + if($obj->Type == 'epub-advanced'){ + $obj->Type = 'epub (advanced)'; + } + + $obj->UpdatedString = $obj->Updated->format('M j'); + // Add a period to the abbreviated month, but not if it's May (the only 3-letter month) + $obj->UpdatedString = preg_replace('/^(.+?)(?UpdatedString); + if($obj->Updated->format('Y') != gmdate('Y')){ + $obj->UpdatedString = $obj->Updated->format('M j, Y'); + } + + return $obj; + } + + private static function SortBulkDownloads(array $items): array{ + // This sorts our items in a special order, epub first and advanced epub last + $result = []; + + foreach($items as $key => $item){ + if($item->Type == 'epub'){ + $result[0] = $item; + } + if($item->Type == 'azw3'){ + $result[1] = $item; + } + if($item->Type == 'kepub'){ + $result[2] = $item; + } + if($item->Type == 'xhtml'){ + $result[3] = $item; + } + if($item->Type == 'epub (advanced)'){ + $result[4] = $item; + } + } + + ksort($result); + + return $result; + } + /** * @return array>> */ @@ -237,37 +301,9 @@ class Library{ rsort($files); foreach($files as $file){ - $obj = new stdClass(); - $obj->Updated = new DateTime('@' . filemtime($file)); - $date = new DateTime(); - - preg_match('/se-ebooks-(\d+-\d+)/ius', basename($file), $matches); - if(sizeof($matches) == 2){ - $date = new DateTime($matches[1] . '-01'); - } - - // The type of zip is stored as a filesystem attribute - $obj->Type = exec('attr -g se-ebook-type ' . escapeshellarg($file)); - if($obj->Type == 'epub-advanced'){ - $obj->Type = 'epub (advanced)'; - } - - $obj->Month = $date->format('Y-m'); - $obj->Url = '/bulk-downloads/months/' . $obj->Month . '/' . basename($file); - $obj->Size = Formatter::ToFileSize(filesize($file)); - - // The count of ebooks in each file is stored as a filesystem attribute - $obj->Count = exec('attr -g se-ebook-count ' . escapeshellarg($file)) ?: null; - if($obj->Count !== null){ - $obj->Count = intval($obj->Count); - } - - $obj->UpdatedString = $obj->Updated->format('M j'); - $obj->UpdatedString = preg_replace('/^(.+?)(?UpdatedString); - if($obj->Updated->format('Y') != gmdate('Y')){ - $obj->UpdatedString = $obj->Updated->format('M j, Y'); - } + $obj = self::FillBulkDownloadObject($file, 'months'); + $date = new DateTime($obj->Label . '-01'); $year = $date->format('Y'); $month = $date->format('F'); @@ -285,61 +321,54 @@ class Library{ // Sort the downloads by filename extension foreach($years as $year => $months){ foreach($months as $month => $items){ - usort($items, function($a, $b){ return $a->Type <=> $b->Type; }); - - // We have to reassign it because the foreach created a clone of the array - $years[$year][$month] = $items; + $years[$year][$month] = self::SortBulkDownloads($items); } } apcu_store('bulk-downloads-years', $years); - // Generate bulk downloads by year + // Generate bulk downloads by subject $files = glob(WEB_ROOT . '/bulk-downloads/subjects/*/*.zip'); sort($files); foreach($files as $file){ - $obj = new stdClass(); - $obj->Url = '/bulk-downloads/' . basename($file); - $obj->Size = Formatter::ToFileSize(filesize($file)); - $obj->Updated = new DateTime('@' . filemtime($file)); + $obj = self::FillBulkDownloadObject($file, 'subjects'); - // The count of ebooks in each file is stored as a filesystem attribute - $obj->Count = exec('attr -g se-ebook-count ' . escapeshellarg($file)) ?: null; - if($obj->Count !== null){ - $obj->Count = intval($obj->Count); + if(!isset($subjects[$obj->UrlLabel])){ + $subjects[$obj->UrlLabel] = []; } - // The subject of the batch is stored as a filesystem attribute - $obj->Subject = exec('attr -g se-subject ' . escapeshellarg($file)) ?: null; - if($obj->Subject === null){ - $obj->Subject = str_replace('se-ebooks-', '', basename($file, '.zip')); - } - - // The type of zip is stored as a filesystem attribute - $obj->Type = exec('attr -g se-ebook-type ' . escapeshellarg($file)); - if($obj->Type == 'epub-advanced'){ - $obj->Type = 'epub (advanced)'; - } - - $obj->UpdatedString = $obj->Updated->format('M j'); - $obj->UpdatedString = preg_replace('/^(.+?)(?UpdatedString); - if($obj->Updated->format('Y') != gmdate('Y')){ - $obj->UpdatedString = $obj->Updated->format('M j, Y'); - } - - if(!isset($subjects[$obj->Subject])){ - $subjects[$obj->Subject] = []; - } - - $subjects[$obj->Subject][] = $obj; + $subjects[$obj->UrlLabel][] = $obj; } - // Subjects downloads are already correctly sorted + foreach($subjects as $subject => $items){ + $subjects[$subject] = self::SortBulkDownloads($items); + } apcu_store('bulk-downloads-subjects', $subjects); - return ['years' => $years, 'subjects' => $subjects]; + + // Generate bulk downloads by collection + $files = glob(WEB_ROOT . '/bulk-downloads/collections/*/*.zip'); + sort($files); + + foreach($files as $file){ + $obj = self::FillBulkDownloadObject($file, 'collections'); + + if(!isset($collections[$obj->UrlLabel])){ + $collections[$obj->UrlLabel] = []; + } + + $collections[$obj->UrlLabel][] = $obj; + } + + foreach($collections as $collection => $items){ + $collections[$collection] = self::SortBulkDownloads($items); + } + + apcu_store('bulk-downloads-collections', $collections); + + return ['years' => $years, 'subjects' => $subjects, 'collections' => $collections]; } public static function RebuildCache(): void{ diff --git a/lib/User.php b/lib/User.php index 58007c0f..79d84edc 100644 --- a/lib/User.php +++ b/lib/User.php @@ -83,4 +83,21 @@ class User extends PropertiesBase{ return $result[0]; } + + // Get a user if by either email or uuid, ONLY IF they're either a patron or have a valid API key. + public static function GetByPatronIdentifier(?string $identifier): User{ + if($identifier === null){ + throw new Exceptions\InvalidUserException(); + } + + $result = Db::Query('SELECT u.* from Patrons p inner join Users u using (UserId) where p.Ended is null and (u.Email = ? or u.Uuid = ?) + union + select u.* from ApiKeys fu inner join Users u using (UserId) where fu.Ended is null and (u.Email = ? or u.Uuid = ?)', [$identifier, $identifier, $identifier, $identifier], 'User'); + + if(sizeof($result) == 0){ + throw new Exceptions\InvalidUserException(); + } + + return $result[0]; + } } diff --git a/scripts/generate-bulk-downloads b/scripts/generate-bulk-downloads index 2795bd97..b24bcdaf 100755 --- a/scripts/generate-bulk-downloads +++ b/scripts/generate-bulk-downloads @@ -11,10 +11,12 @@ $webRoot = $options['webroot'] ?? WEB_ROOT; $ebooksByMonth = []; $lastUpdatedTimestampsByMonth = []; $subjects = []; +$collections = []; $ebooksBySubject = []; $lastUpdatedTimestampsBySubject = []; +$lastUpdatedTimestampsByCollection = []; -function CreateZip(string $filePath, array $ebooks, string $type, string $webRoot, ?string $subject = null, ?string $month = null): void{ +function CreateZip(string $filePath, array $ebooks, string $type, string $webRoot, string $label): void{ $tempFilename = tempnam(sys_get_temp_dir(), "se-ebooks"); $zip = new ZipArchive(); @@ -70,14 +72,7 @@ function CreateZip(string $filePath, array $ebooks, string $type, string $webRoo exec('attr -q -s se-ebook-type -V ' . escapeshellarg($type) . ' ' . escapeshellarg($filePath)); - // If we're passed a subject, add it as a file attribute too - if($subject !== null){ - exec('attr -q -s se-subject -V ' . escapeshellarg($subject) . ' ' . escapeshellarg($filePath)); - } - - if($month !== null){ - exec('attr -q -s se-month -V ' . escapeshellarg($month) . ' ' . escapeshellarg($filePath)); - } + exec('attr -q -s se-label -V ' . escapeshellarg($label) . ' ' . escapeshellarg($filePath)); } // Iterate over all ebooks and arrange them by publication month @@ -112,6 +107,22 @@ foreach(Library::GetEbooksFromFilesystem($webRoot) as $ebook){ $lastUpdatedTimestampsBySubject[$tag->Name] = $updatedTimestamp; } } + + // Add to the 'books by collection' list + foreach($ebook->Collections as $collection){ + // Add the book's subjects to the main subjects list + if(!in_array($collection->Name, $collections)){ + $collections[] = $collection->Name; + $lastUpdatedTimestampsByCollection[$collection->Name] = $updatedTimestamp; + } + + // Sort this ebook by subject + $ebooksByCollection[$collection->Name][] = $ebook; + + if($updatedTimestamp > $lastUpdatedTimestampsByCollection[$collection->Name]){ + $lastUpdatedTimestampsByCollection[$collection->Name] = $updatedTimestamp; + } + } } $types = ['epub', 'epub-advanced', 'azw3', 'kepub', 'xhtml']; @@ -125,7 +136,7 @@ foreach($ebooksByMonth as $month => $ebooks){ if(!file_exists($filePath) || filemtime($filePath) < $lastUpdatedTimestampsByMonth[$month]){ print('Creating ' . $filePath . "\n"); - CreateZip($filePath, $ebooks, $type, $webRoot, null, $month); + CreateZip($filePath, $ebooks, $type, $webRoot, $month); } } } @@ -140,7 +151,22 @@ foreach($ebooksBySubject as $subject => $ebooks){ if(!file_exists($filePath) || filemtime($filePath) < $lastUpdatedTimestampsBySubject[$subject]){ print('Creating ' . $filePath . "\n"); - CreateZip($filePath, $ebooks, $type, $webRoot, $subject, null); + CreateZip($filePath, $ebooks, $type, $webRoot, $subject); + } + } +} + +foreach($ebooksByCollection as $collection => $ebooks){ + foreach($types as $type){ + $urlSafeCollection = Formatter::MakeUrlSafe($collection); + $filename = 'se-ebooks-' . $urlSafeCollection . '-' . $type . '.zip'; + $filePath = $webRoot . '/bulk-downloads/collections/' . $urlSafeCollection . '/'. $filename; + + // If the file doesn't exist, or if the content.opf last updated time is newer than the file modification time + if(!file_exists($filePath) || filemtime($filePath) < $lastUpdatedTimestampsByCollection[$collection]){ + print('Creating ' . $filePath . "\n"); + + CreateZip($filePath, $ebooks, $type, $webRoot, $collection); } } } diff --git a/templates/BulkDownloadTable.php b/templates/BulkDownloadTable.php new file mode 100644 index 00000000..afde3254 --- /dev/null +++ b/templates/BulkDownloadTable.php @@ -0,0 +1,24 @@ + + + + + + + + + + + $items){ ?> + + + + + + + + + + + + +
EbooksUpdatedEbook format
Label) ?>Count)) ?>UpdatedString) ?>Type ?>(Size) ?>)
diff --git a/www/bulk-downloads/get.php b/www/bulk-downloads/get.php new file mode 100644 index 00000000..04b267ed --- /dev/null +++ b/www/bulk-downloads/get.php @@ -0,0 +1,57 @@ + 'Download ', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?> +
+
+

Download the Label ?> Collection

+ $exception]) ?> + +

Patrons circle members can download zip files containing all of the ebooks in a collection. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

+

If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download this collection.

+ +

Select the ebook format in which you’d like to download this collection.

+

You can also read about which ebook format to download.

+ + 'Collection', 'collections' => [$bulkDownloadCollection]]); ?> +
+
+ diff --git a/www/bulk-downloads/index.php b/www/bulk-downloads/index.php index ab96e9c6..c38988cd 100644 --- a/www/bulk-downloads/index.php +++ b/www/bulk-downloads/index.php @@ -14,15 +14,18 @@ if(isset($_SERVER['PHP_AUTH_USER'])){ $years = []; $subjects = []; +$collections = []; try{ $years = apcu_fetch('bulk-downloads-years'); $subjects = apcu_fetch('bulk-downloads-subjects'); + $collections = apcu_fetch('bulk-downloads-collections'); } catch(Safe\Exceptions\ApcuException $ex){ $result = Library::RebuildBulkDownloadsCache(); $years = $result['years']; $subjects = $result['subjects']; + $collections = $result['collections']; } ?> 'Bulk Ebook Downloads', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?> @@ -35,11 +38,7 @@ catch(Safe\Exceptions\ApcuException $ex){ A gentleman in regency-era dress buys books from a bookseller. - + $forbiddenException]) ?>

Patrons circle members can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can join the Patrons Circle with a small donation in support of our continuing mission to create free, beautiful digital literature.

These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook.

@@ -47,54 +46,36 @@ catch(Safe\Exceptions\ApcuException $ex){

Downloads by subject

- - - - - - - - - - - $items){ ?> - - - - + 'Subject', 'collections' => $subjects]); ?> + - - - - - - - -
EbooksUpdatedDownload
Count)) ?>UpdatedString) ?>Type ?>(Size) ?>)
+
+

Downloads by collection

+ 'Collection', 'collections' => $collections]); ?>
-

Downloads by year

+

Downloads by month

$months){ - $yearHeader = Formatter::ToPlainText((string)$year); + $yearHeader = Formatter::ToPlainText($year); ?> - + - + $items){ - $monthHeader = $items[0]->Month; + $monthHeader = Formatter::ToPlainText($month); ?> - + diff --git a/www/css/core.css b/www/css/core.css index 54006efa..9272d1e4 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -63,6 +63,7 @@ --light-input-hover: #000; --light-input-border: #777; --light-input-outline: #000; + --light-table-row-hover: #dddbd5; --dark-body-bg: #2c3035; --dark-body-text: #fff; @@ -74,6 +75,7 @@ --dark-input-border: #aaa; --dark-input-hover: #118460; --dark-input-outline: #fff; + --dark-table-row-hover: #373b3f; --body-text: var(--light-body-text); --header: var(--light-header); @@ -87,6 +89,7 @@ --input-border: var(--light-input-border); --input-outline: var(--light-input-outline); --link-highlight: var(--button); + --table-row-hover: var(--light-table-row-hover); } /* Start CSS reset */ @@ -676,13 +679,17 @@ ul.message.error li:only-child{ } .bulk-downloads > p, -.bulk-downloads > section > h2{ +.bulk-downloads h2{ width: 100%; max-width: 40rem; margin-left: auto; margin-right: auto; } +.bulk-downloads h2{ + text-align: center; +} + .download-list{ margin: auto; } @@ -692,9 +699,11 @@ ul.message.error li:only-child{ } .download-list thead tr.mid-header:first-child > *{ - padding-top: 1rem; + padding-top: 2rem; } +.download-list th.row-header, +.download-list .mid-header th:first-child, .download-list .mid-header th:last-child{ text-align: left; } @@ -728,6 +737,7 @@ ul.message.error li:only-child{ .download-list tbody .row-header{ font-weight: bold; + white-space: normal; } .download-list tbody tr td, @@ -735,6 +745,7 @@ ul.message.error li:only-child{ border-top: 1px dashed var(--table-border); } +.download-list tbody tr:first-child > *, .download-list tbody tr.year-header > *, .download-list tbody tr.year-header + tr > *, .download-list tbody tr.mid-header tr > *, @@ -743,11 +754,22 @@ ul.message.error li:only-child{ border: none; } +.download-list tbody tr:not([class]):hover > *{ + background: var(--table-row-hover); +} + +.download-list tbody tr:only-child:not([class]):hover > *{ + background: unset; /* Don't highlight on hover if there's only one row */ +} + +h2 + .download-list tr.year-header:first-child th{ + padding-top: 2rem; +} + .download-list .year-header th{ padding-top: 4rem; font-size: 1.4rem; font-family: "League Spartan", Arial, sans-serif; - margin-top: 4rem; line-height: 1.2; letter-spacing: 1px; text-transform: uppercase; @@ -2278,6 +2300,12 @@ article.step-by-step-guide ol ol{ width: 100%; } +.download-collection{ + display: flex; + justify-content: center; + margin-bottom: 2rem; +} + abbr.acronym{ font-variant: all-small-caps; } diff --git a/www/css/dark.css b/www/css/dark.css index dfa21fe9..2e9098a6 100644 --- a/www/css/dark.css +++ b/www/css/dark.css @@ -10,6 +10,7 @@ --input-border: var(--dark-input-border); --input-outline: var(--dark-input-outline); --link-highlight: var(--button-highlight); /* lighter looks better in dark mode */ + --table-row-hover: var(--dark-table-row-hover); } main.front-page > section > section figure img{ diff --git a/www/ebooks/ebook.php b/www/ebooks/ebook.php index b10a25b1..7ff2910d 100644 --- a/www/ebooks/ebook.php +++ b/www/ebooks/ebook.php @@ -338,10 +338,14 @@ catch(Exceptions\InvalidEbookException $ex){
  • - Type == SOURCE_PROJECT_GUTENBERG){ ?>Transcription at Project Gutenberg - Type == SOURCE_PROJECT_GUTENBERG_AUSTRALIA){ ?>Transcription at Project Gutenberg Australia - Type == SOURCE_PROJECT_GUTENBERG_CANADA){ ?>Transcription at Project Gutenberg Canada - Type == SOURCE_WIKISOURCE){ ?>Transcription at Wikisource + Type == SOURCE_PROJECT_GUTENBERG){ ?>Transcription at Project Gutenberg + Type == SOURCE_PROJECT_GUTENBERG_AUSTRALIA){ ?>Transcription at Project Gutenberg Australia + Type == SOURCE_PROJECT_GUTENBERG_CANADA){ ?>Transcription at Project Gutenberg Canada + Type == SOURCE_WIKISOURCE){ ?>Transcription at Wikisource + Type == SOURCE_FADED_PAGE){ ?>Transcription at Faded Page + + Transcription +

  • @@ -355,10 +359,10 @@ catch(Exceptions\InvalidEbookException $ex){
  • - Type == SOURCE_INTERNET_ARCHIVE){ ?>Page scans at the Internet Archive - Type == SOURCE_HATHI_TRUST){ ?>Page scans at HathiTrust - Type == SOURCE_GOOGLE_BOOKS){ ?>Page scans at Google Books - Type == SOURCE_FADED_PAGE){ ?>Transcription at Faded Page + Type == SOURCE_INTERNET_ARCHIVE){ ?>Page scans at the Internet Archive + Type == SOURCE_HATHI_TRUST){ ?>Page scans at HathiTrust + Type == SOURCE_GOOGLE_BOOKS){ ?>Page scans at Google Books + Page scans

  • diff --git a/www/ebooks/index.php b/www/ebooks/index.php index b79f80d7..8510a567 100644 --- a/www/ebooks/index.php +++ b/www/ebooks/index.php @@ -135,6 +135,9 @@ catch(Exceptions\InvalidCollectionException $ex){ $query, 'tags' => $tags, 'sort' => $sort, 'view' => $view, 'perPage' => $perPage]) ?> + 1){ ?> +

    Download entire collection

    +

    No ebooks matched your filters. You can try different filters, or browse all of our ebooks.

    @@ -151,6 +154,7 @@ catch(Exceptions\InvalidCollectionException $ex){ href="/ebooks/?page=&" rel="next" aria-disabled="true">Next +

    We also have bulk ebook downloads available, as well as ebook catalog feeds for use directly in your ereader app or RSS reader.

    0 && $query == '' && sizeof($tags) == 0 && $collection === null && $page == 1){ ?>
    Month Ebooks UpdatedDownloadEbook format
    Count)) ?> UpdatedString) ?>