From 12b79b5dcd4b4c50b261b8c5f12b0802d6d6306e Mon Sep 17 00:00:00 2001 From: Alex Cabal Date: Sat, 9 Jul 2022 17:22:13 -0500 Subject: [PATCH] Split bulk downloads into file type and cache output --- .gitignore | 3 +- lib/Formatter.php | 8 +- scripts/generate-bulk-downloads | 146 +++++++++++++++++ scripts/generate-feeds | 2 +- scripts/generate-monthly-downloads | 85 ---------- www/css/core.css | 96 ++++++----- www/donate/index.php | 9 +- www/ebooks/index.php | 1 + www/feeds/401.php | 2 +- www/feeds/index.php | 2 +- www/patrons-circle/downloads/index.php | 219 ++++++++++++++++++++----- 11 files changed, 395 insertions(+), 178 deletions(-) create mode 100755 scripts/generate-bulk-downloads delete mode 100755 scripts/generate-monthly-downloads diff --git a/.gitignore b/.gitignore index 1fd4a446..f3041e30 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ composer.lock www/manual/* !www/manual/index.php config/php/fpm/standardebooks.org-secrets.ini -www/patrons-circle/downloads/*.zip +www/patrons-circle/downloads/months +www/patrons-circle/downloads/subjects diff --git a/lib/Formatter.php b/lib/Formatter.php index eb63c367..342d1373 100644 --- a/lib/Formatter.php +++ b/lib/Formatter.php @@ -51,16 +51,16 @@ class Formatter{ $output = number_format(round($bytes / 1048576, 1), 1) . 'M'; } elseif($bytes >= 1024){ - $output = number_format($bytes / 1024, 2) . 'KB'; + $output = number_format($bytes / 1024, 0) . 'KB'; } elseif($bytes > 1){ - $output = $bytes . ' bytes'; + $output = $bytes . 'b'; } elseif($bytes == 1){ - $output = $bytes . ' byte'; + $output = $bytes . 'b'; } else{ - $output = '0 bytes'; + $output = '0b'; } return $output; diff --git a/scripts/generate-bulk-downloads b/scripts/generate-bulk-downloads new file mode 100755 index 00000000..c0e6a981 --- /dev/null +++ b/scripts/generate-bulk-downloads @@ -0,0 +1,146 @@ +#!/usr/bin/php +open($tempFilename, ZipArchive::CREATE) !== true){ + print('Can\'t open file: ' . $tempFilename . "\n"); + } + + foreach($ebooks as $ebook){ + if($type == 'epub' && $ebook->EpubUrl !== null){ + $ebookFilePath = $webRoot . '/' . $ebook->EpubUrl; + $zip->addFile($ebookFilePath, basename($ebookFilePath)); + } + + if($type == 'azw3' && $ebook->Azw3Url !== null){ + $ebookFilePath = $webRoot . '/' . $ebook->Azw3Url; + $zip->addFile($ebookFilePath, basename($ebookFilePath)); + } + + if($type == 'kepub' && $ebook->KepubUrl !== null){ + $ebookFilePath = $webRoot . '/' . $ebook->KepubUrl; + $zip->addFile($ebookFilePath, basename($ebookFilePath)); + } + + if($type == 'epub-advanced' && $ebook->AdvancedEpubUrl !== null){ + $ebookFilePath = $webRoot . '/' . $ebook->AdvancedEpubUrl; + $zip->addFile($ebookFilePath, basename($ebookFilePath)); + } + + if($type == 'xhtml' && $ebook->TextSinglePageUrl !== null){ + $ebookFilePath = $webRoot . '/' . $ebook->TextSinglePageUrl . '.xhtml'; + + // Strip the navigation header that was added as part of the deploy process + $xhtml = file_get_contents($ebookFilePath); + $xhtml = preg_replace('|
|ius', '', $xhtml); + + $zip->addFromString(str_replace('single-page', $ebook->UrlSafeIdentifier, basename($ebookFilePath)), $xhtml); + } + } + + $zip->close(); + + $dir = dirname($filePath); + if(!is_dir($dir)){ + mkdir($dir, 0775, true); + } + + rename($tempFilename, $filePath); + + // Set a filesystem attribute for the number of ebooks in the file. This will be used + // to display that number on the downloads page. + exec('attr -q -s se-ebook-count -V ' . escapeshellarg(sizeof($ebooks)) . ' ' . escapeshellarg($filePath)); + + 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)); + } +} + +// Iterate over all ebooks and arrange them by publication month +foreach(Library::GetEbooksFromFilesystem($webRoot) as $ebook){ + $timestamp = $ebook->Created->format('Y-m'); + $updatedTimestamp = $ebook->Updated->getTimestamp(); + + if(!isset($ebooksByMonth[$timestamp])){ + $ebooksByMonth[$timestamp] = []; + $lastUpdatedTimestampsByMonth[$timestamp] = $updatedTimestamp; + } + + // Add to the 'ebooks by month' list + $ebooksByMonth[$timestamp][] = $ebook; + + if($updatedTimestamp > $lastUpdatedTimestampsByMonth[$timestamp]){ + $lastUpdatedTimestampsByMonth[$timestamp] = $updatedTimestamp; + } + + // Add to the 'books by subject' list + foreach($ebook->Tags as $tag){ + // Add the book's subjects to the main subjects list + if(!in_array($tag->Name, $subjects)){ + $subjects[] = $tag->Name; + $lastUpdatedTimestampsBySubject[$tag->Name] = $updatedTimestamp; + } + + // Sort this ebook by subject + $ebooksBySubject[$tag->Name][] = $ebook; + + if($updatedTimestamp > $lastUpdatedTimestampsBySubject[$tag->Name]){ + $lastUpdatedTimestampsBySubject[$tag->Name] = $updatedTimestamp; + } + } +} + +$types = ['epub', 'epub-advanced', 'azw3', 'kepub', 'xhtml']; + +foreach($ebooksByMonth as $month => $ebooks){ + foreach($types as $type){ + $filename = 'se-ebooks-' . $month . '-' . $type . '.zip'; + $filePath = $webRoot . '/patrons-circle/downloads/months/' . $month . '/' . $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) < $lastUpdatedTimestampsByMonth[$month]){ + print('Creating ' . $filePath . "\n"); + + CreateZip($filePath, $ebooks, $type, $webRoot, null, $month); + } + } +} + +foreach($ebooksBySubject as $subject => $ebooks){ + foreach($types as $type){ + $urlSafeSubject = Formatter::MakeUrlSafe($subject); + $filename = 'se-ebooks-' . $urlSafeSubject . '-' . $type . '.zip'; + $filePath = $webRoot . '/patrons-circle/downloads/subjects/' . $urlSafeSubject . '/'. $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) < $lastUpdatedTimestampsBySubject[$subject]){ + print('Creating ' . $filePath . "\n"); + + CreateZip($filePath, $ebooks, $type, $webRoot, $subject, null); + } + } +} diff --git a/scripts/generate-feeds b/scripts/generate-feeds index 7d2c4e88..cccb0659 100755 --- a/scripts/generate-feeds +++ b/scripts/generate-feeds @@ -29,7 +29,7 @@ $allEbooks = []; $newestEbooks = []; $subjects = []; $ebooksBySubject = []; -$ebooksPerNewestEbooksFeed = 30; +$ebooksPerNewestEbooksFeed = 15; if(!is_dir($webRoot . '/feeds/opds/subjects')){ mkdir($webRoot . '/feeds/opds/subjects'); diff --git a/scripts/generate-monthly-downloads b/scripts/generate-monthly-downloads deleted file mode 100755 index 478e2bc4..00000000 --- a/scripts/generate-monthly-downloads +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/php -Created->format('Y-m'); - $updatedTimestamp = $ebook->Updated->getTimestamp(); - - if(!isset($ebooksByMonth[$timestamp])){ - $ebooksByMonth[$timestamp] = []; - $lastUpdatedTimestamps[$timestamp] = $updatedTimestamp; - } - - $ebooksByMonth[$timestamp][] = $ebook; - if($updatedTimestamp > $lastUpdatedTimestamps[$timestamp]){ - $lastUpdatedTimestamps[$timestamp] = $updatedTimestamp; - } -} - -foreach($ebooksByMonth as $month => $ebooks){ - $filename = 'se-ebooks-' . $month . '.zip'; - $filePath = $webRoot . '/patrons-circle/downloads/' . $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) < $lastUpdatedTimestamps[$month]){ - print('Creating ' . $filePath . "\n"); - - $tempFilename = tempnam(sys_get_temp_dir(), "se-ebooks"); - - $zip = new ZipArchive(); - - if($zip->open($tempFilename, ZipArchive::CREATE) !== true){ - print('Can\'t open file: ' . $tempFilename . "\n"); - continue; - } - - foreach($ebooks as $ebook){ - if($ebook->EpubUrl !== null){ - $ebookFilePath = $webRoot . '/' . $ebook->EpubUrl; - $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath)); - } - - if($ebook->Azw3Url !== null){ - $ebookFilePath = $webRoot . '/' . $ebook->Azw3Url; - $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath)); - } - - if($ebook->KepubUrl !== null){ - $ebookFilePath = $webRoot . '/' . $ebook->KepubUrl; - $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath)); - } - - if($ebook->AdvancedEpubUrl !== null){ - $ebookFilePath = $webRoot . '/' . $ebook->AdvancedEpubUrl; - $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath)); - } - - if($ebook->TextSinglePageUrl !== null){ - $ebookFilePath = $webRoot . '/' . $ebook->TextSinglePageUrl . '.xhtml'; - - // Strip the navigation header that was added as part of the deploy process - $xhtml = file_get_contents($ebookFilePath); - $xhtml = preg_replace('|
|ius', '', $xhtml); - - $zip->addFromString($ebook->UrlSafeIdentifier . '/' . str_replace('single-page', $ebook->UrlSafeIdentifier, basename($ebookFilePath)), $xhtml); - } - } - - $zip->close(); - - rename($tempFilename, $filePath); - - // Set a filesystem attribute for the number of ebooks in the file. This will be used - // to display that number on the downloads page. - exec('attr -q -s ebook-count -V ' . escapeshellarg(sizeof($ebooks)) . ' ' . escapeshellarg($filePath)); - } -} diff --git a/www/css/core.css b/www/css/core.css index e7d5b97a..88318d56 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -675,66 +675,76 @@ ul.message.error li:only-child{ margin-left: 0; } -.bulk-downloads > p{ +.bulk-downloads > p, +.bulk-downloads > section > h2{ width: 100%; max-width: 40rem; margin-left: auto; margin-right: auto; } -ul.download-list thead{ +.download-list{ + margin: auto; +} + +.download-list .mid-header{ font-style: italic; } -ul.download-list table thead td{ - padding-top: 0; - padding-bottom: 0; +.download-list thead tr.mid-header:first-child > *{ + padding-top: 1rem; } -ul.download-list table td{ - padding: .25rem; +.download-list .mid-header th:last-child{ + text-align: left; } -ul.download-list table td + td{ - text-align: right; +.download-list td, +.download-list th{ + padding: .25rem .5rem; + hyphens: none; white-space: nowrap; } -ul.download-list tbody tr:not(:last-child) td{ - border-bottom: 1px dashed var(--table-border); +.download-list th{ + font-weight: normal; + text-align: right; } -ul.download-list tbody td + td{ +.download-list .number{ + text-align: right; +} + +.download-list td.download{ + padding-right: 0; + color: var(--body-text); +} + +.download-list td.download + td{ + padding-left: .25rem; + font-size: .75em; color: var(--sub-text); } -main > section.narrow > ul.download-list{ - width: auto; - max-width: none; +.download-list tbody .row-header{ + font-weight: bold; } -ul.download-list{ - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 4rem; - list-style: none; - margin-top: 4rem; +.download-list tbody tr td, +.download-list tbody tr th{ + border-top: 1px dashed var(--table-border); } -ul.download-list > li{ - margin: 0; +.download-list tbody tr.year-header > *, +.download-list tbody tr.year-header + tr > *, +.download-list tbody tr.mid-header tr > *, +.download-list tbody tr.mid-header + tr td, +.download-list tbody tr.mid-header + tr th{ + border: none; } -ul.download-list ul{ - list-style: none; - margin: 0; -} - -ul.download-list > li li:first-of-type{ - margin-top: 1rem; -} - -ul.download-list p.header{ +.download-list .year-header th{ + padding-top: 4rem; font-size: 1.4rem; font-family: "League Spartan", Arial, sans-serif; margin-top: 4rem; @@ -742,8 +752,6 @@ ul.download-list p.header{ letter-spacing: 1px; text-transform: uppercase; color: var(--header); - margin-top: 0; - margin-bottom: 1rem; text-align: center; } @@ -2507,6 +2515,14 @@ aside button.close:active{ right: -1px; } +.feeds-alert{ + border-top: 1px dashed var(--sub-text); + padding-top: 2rem; + font-style: italic; + text-align: center; + margin-top: 2rem; +} + @keyframes progress{ 0%{ background-position: -60px 0px; @@ -2735,8 +2751,10 @@ ul.feed p{ } @media(max-width: 1200px){ - ul.download-list{ - grid-template-columns: 1fr 1fr; + .download-list{ + overflow-x: scroll; + display: block; /* needed to make overflow work */ + width: 100%; } } @@ -2918,10 +2936,6 @@ ul.feed p{ font-size: 1rem; margin: 0; } - - ul.download-list{ - grid-template-columns: 1fr; - } } @media(max-width: 730px){ diff --git a/www/donate/index.php b/www/donate/index.php index 4b4a67a8..eb6af1d9 100644 --- a/www/donate/index.php +++ b/www/donate/index.php @@ -42,10 +42,13 @@ require_once('Core.php');

Get notified of new ebooks in your news client with our Atom or RSS feeds.

  • -

    Parse and process the feeds to use our ebooks in your personal software projects. (Organizations, see corporate sponsorship instead.)

    +

    Parse and process the feeds to use our ebooks in your personal software projects.

  • +
  • +

    Access to bulk ebook downloads to easily download entire months’ worth of ebooks at once.

    +
  • The ability to submit a book for inclusion on our Wanted Ebooks list, once per quarter. (Submissions must conform to our collections policy and are subject to approval.)

  • @@ -53,6 +56,7 @@ require_once('Core.php');

    The right to periodically vote on a selection from our Wanted Ebooks list to choose an ebook for immediate production. The resulting ebook will be a permanent addition to our online catalog of free digital literature.

    +

    Membership in the Patrons Circle is limited to individuals only. Organizations, see corporate sponsorship instead.

    Donate $10/month or more Donate $100 or more @@ -141,6 +145,9 @@ require_once('Core.php');

  • Get access to our OPDS, Atom, and RSS ebook feeds for use by your organization for the duration of your sponsorship. We can also produce different kinds of feeds to meet your needs, like ONIX feeds.

  • +
  • +

    Get access to bulk ebook downloads to easily download large categories of ebooks, all at once.

    +
  • To inquire about sponsorship options, contact the Standard Ebooks Editor-in-Chief.

    diff --git a/www/ebooks/index.php b/www/ebooks/index.php index 50decdca..e35c6399 100644 --- a/www/ebooks/index.php +++ b/www/ebooks/index.php @@ -151,6 +151,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){ ?> diff --git a/www/feeds/401.php b/www/feeds/401.php index 2edd3949..1f2cdd1e 100644 --- a/www/feeds/401.php +++ b/www/feeds/401.php @@ -54,7 +54,7 @@ if($type == 'atom'){ -

    OPDS feeds are designed for use with ereading apps on your phone or tablet, or with ereading systems like KOreader. Add our OPDS feed to your ereading app to search, browse, and download from our entire catalog, directly in your ereader.

    +

    OPDS feeds, or “catalogs,” can be added to ereading apps on phones and tablets to search, browse, and download from our entire catalog, directly in your ereader. Most modern ereading apps support OPDS catalogs.

    They’re also perfect for scripting, or for libraries or other organizations who wish to download and process our catalog of ebooks.

    RSS feeds are the predecessors of Atom feeds. They contain less information than Atom feeds, but might be better supported by some news readers.

    diff --git a/www/feeds/index.php b/www/feeds/index.php index 1ac6511c..8ecb44ae 100644 --- a/www/feeds/index.php +++ b/www/feeds/index.php @@ -14,7 +14,7 @@ require_once('Core.php');

    OPDS 1.2 feeds

    -

    OPDS feeds, or “catalogs,” can be added to ereading apps on phones and tablets, or to ereading systems like KOreader. Add our OPDS feed to your ereading app to search, browse, and download from our entire catalog, directly in your ereader.

    +

    OPDS feeds, or “catalogs,” can be added to ereading apps on phones and tablets to search, browse, and download from our entire catalog, directly in your ereader. Most modern ereading apps support OPDS catalogs.

    They’re also perfect for scripting, or for libraries or other organizations who wish to download and process our catalog of ebooks.

    • diff --git a/www/patrons-circle/downloads/index.php b/www/patrons-circle/downloads/index.php index 42a09abc..89d35dfd 100644 --- a/www/patrons-circle/downloads/index.php +++ b/www/patrons-circle/downloads/index.php @@ -2,96 +2,229 @@ require_once('Core.php'); use Safe\DateTime; +use function Safe\apcu_fetch; use function Safe\filemtime; use function Safe\filesize; use function Safe\glob; use function Safe\gmdate; +use function Safe\preg_match; +use function Safe\sort; use function Safe\rsort; +use function Safe\usort; -$ex = null; +$forbiddenException = null; if(isset($_SERVER['PHP_AUTH_USER'])){ // We get here if the user entered an invalid HTTP Basic Auth username, // and this page was served as the 401 page. - $ex = new Exceptions\InvalidPatronException(); + $forbiddenException = new Exceptions\InvalidPatronException(); } -$files = glob(WEB_ROOT . '/patrons-circle/downloads/*.zip'); -rsort($files); - +// Process ebooks by year $years = []; -foreach($files as $file){ - $obj = new stdClass(); - $date = new DateTime(str_replace('se-ebooks-', '', basename($file, '.zip')) . '-01'); - $updated = new DateTime('@' . filemtime($file)); - $obj->Month = $date->format('F'); - $obj->Url = '/patrons-circle/downloads/' . basename($file); - $obj->Size = Formatter::ToFileSize(filesize($file)); - $obj->Updated = $updated->format('M i'); - // The count of ebooks in each file is stored as a filesystem attribute - $obj->Count = exec('attr -g ebook-count ' . escapeshellarg($file)) ?: null; - if($obj->Count !== null){ - $obj->Count = intval($obj->Count); +try{ + $years = apcu_fetch('bulk-downloads-years'); +} +catch(Safe\Exceptions\ApcuException $ex){ + // Nothing in the cache, generate the files + + $files = glob(WEB_ROOT . '/patrons-circle/downloads/months/*/*.zip'); + 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 = '/patrons-circle/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'); + if($obj->Updated->format('Y') != gmdate('Y')){ + $obj->UpdatedString = $obj->Updated->format('M j, Y'); + } + + $year = $date->format('Y'); + $month = $date->format('F'); + + if(!isset($years[$year])){ + $years[$year] = []; + } + + if(!isset($years[$year][$month])){ + $years[$year][$month] = []; + } + + $years[$year][$month][] = $obj; } - if($updated->format('Y') != gmdate('Y')){ - $obj->Updated = $obj->Updated . $updated->format(', Y'); + // 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; + } } - $year = $date->format('Y'); + apcu_store('bulk-downloads-years', $years, 43200); // 12 hours +} - if(!isset($years[$year])){ - $years[$year] = []; + +$subjects = []; +// Process ebooks by subject +try{ + $subjects = apcu_fetch('bulk-downloads-subjects'); +} +catch(Safe\Exceptions\ApcuException $ex){ + // Nothing in the cache, generate the files + $files = glob(WEB_ROOT . '/patrons-circle/downloads/subjects/*/*.zip'); + sort($files); + + foreach($files as $file){ + $obj = new stdClass(); + $obj->Url = '/patrons-circle/downloads/' . basename($file); + $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->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'); + 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; } - $years[$year][] = $obj; + // Subjects downloads are already correctly sorted + + apcu_store('bulk-downloads-subjects', $subjects, 43200); // 12 hours } ?> 'Bulk Ebook Downloads', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?>
      -
      +

      Bulk Ebook Downloads

      A gentleman in regency-era dress buys books from a bookseller. - +
      • -

        getMessage()) ?>

        +

        getMessage()) ?>

      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.

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

      -
        - $items){ ?> -
      • -

        - + +
        +

        Downloads by subject

        +
        - - - - + + + + + + - + $items){ ?> - - - - + + + + + + + +
        EbooksSizeUpdated
        EbooksUpdatedDownload
        Month) ?>Count)) ?>Size) ?>Updated) ?>Count)) ?>UpdatedString) ?>Type ?>(Size) ?>)
        -
      • - -
      +
      + +
      +

      Downloads by year

      + + + $months){ + $yearHeader = Formatter::ToPlainText((string)$year); + ?> + + + + + + + + + + + $items){ + $monthHeader = $items[0]->Month; + ?> + + + + + + + + + + + + + +
      EbooksUpdatedDownload
      Count)) ?>UpdatedString) ?>Type ?>(Size) ?>)
      +