Split bulk downloads into file type and cache output

This commit is contained in:
Alex Cabal 2022-07-09 17:22:13 -05:00
parent 55985b0c2f
commit 12b79b5dcd
11 changed files with 395 additions and 178 deletions

3
.gitignore vendored
View file

@ -14,4 +14,5 @@ composer.lock
www/manual/* www/manual/*
!www/manual/index.php !www/manual/index.php
config/php/fpm/standardebooks.org-secrets.ini config/php/fpm/standardebooks.org-secrets.ini
www/patrons-circle/downloads/*.zip www/patrons-circle/downloads/months
www/patrons-circle/downloads/subjects

View file

@ -51,16 +51,16 @@ class Formatter{
$output = number_format(round($bytes / 1048576, 1), 1) . 'M'; $output = number_format(round($bytes / 1048576, 1), 1) . 'M';
} }
elseif($bytes >= 1024){ elseif($bytes >= 1024){
$output = number_format($bytes / 1024, 2) . 'KB'; $output = number_format($bytes / 1024, 0) . 'KB';
} }
elseif($bytes > 1){ elseif($bytes > 1){
$output = $bytes . ' bytes'; $output = $bytes . 'b';
} }
elseif($bytes == 1){ elseif($bytes == 1){
$output = $bytes . ' byte'; $output = $bytes . 'b';
} }
else{ else{
$output = '0 bytes'; $output = '0b';
} }
return $output; return $output;

146
scripts/generate-bulk-downloads Executable file
View file

@ -0,0 +1,146 @@
#!/usr/bin/php
<?
require_once('/standardebooks.org/web/lib/Core.php');
use function Safe\mkdir;
$longopts = ['webroot:'];
$options = getopt('', $longopts);
$webRoot = $options['webroot'] ?? WEB_ROOT;
$ebooksByMonth = [];
$lastUpdatedTimestampsByMonth = [];
$subjects = [];
$ebooksBySubject = [];
$lastUpdatedTimestampsBySubject = [];
function CreateZip(string $filePath, array $ebooks, string $type, string $webRoot, ?string $subject = null, ?string $month = null): void{
$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");
}
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('|<body><header><nav>.+?</nav></header>|ius', '<body>', $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);
}
}
}

View file

@ -29,7 +29,7 @@ $allEbooks = [];
$newestEbooks = []; $newestEbooks = [];
$subjects = []; $subjects = [];
$ebooksBySubject = []; $ebooksBySubject = [];
$ebooksPerNewestEbooksFeed = 30; $ebooksPerNewestEbooksFeed = 15;
if(!is_dir($webRoot . '/feeds/opds/subjects')){ if(!is_dir($webRoot . '/feeds/opds/subjects')){
mkdir($webRoot . '/feeds/opds/subjects'); mkdir($webRoot . '/feeds/opds/subjects');

View file

@ -1,85 +0,0 @@
#!/usr/bin/php
<?
require_once('/standardebooks.org/web/lib/Core.php');
$longopts = ['webroot:'];
$options = getopt('', $longopts);
$webRoot = $options['webroot'] ?? WEB_ROOT;
$ebooksByMonth = [];
$lastUpdatedTimestamps = [];
// 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] = [];
$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('|<body><header><nav>.+?</nav></header>|ius', '<body>', $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));
}
}

View file

@ -675,66 +675,76 @@ ul.message.error li:only-child{
margin-left: 0; margin-left: 0;
} }
.bulk-downloads > p{ .bulk-downloads > p,
.bulk-downloads > section > h2{
width: 100%; width: 100%;
max-width: 40rem; max-width: 40rem;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
ul.download-list thead{ .download-list{
margin: auto;
}
.download-list .mid-header{
font-style: italic; font-style: italic;
} }
ul.download-list table thead td{ .download-list thead tr.mid-header:first-child > *{
padding-top: 0; padding-top: 1rem;
padding-bottom: 0;
} }
ul.download-list table td{ .download-list .mid-header th:last-child{
padding: .25rem; text-align: left;
} }
ul.download-list table td + td{ .download-list td,
text-align: right; .download-list th{
padding: .25rem .5rem;
hyphens: none;
white-space: nowrap; white-space: nowrap;
} }
ul.download-list tbody tr:not(:last-child) td{ .download-list th{
border-bottom: 1px dashed var(--table-border); 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); color: var(--sub-text);
} }
main > section.narrow > ul.download-list{ .download-list tbody .row-header{
width: auto; font-weight: bold;
max-width: none;
} }
ul.download-list{ .download-list tbody tr td,
display: grid; .download-list tbody tr th{
grid-template-columns: 1fr 1fr 1fr; border-top: 1px dashed var(--table-border);
gap: 4rem;
list-style: none;
margin-top: 4rem;
} }
ul.download-list > li{ .download-list tbody tr.year-header > *,
margin: 0; .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{ .download-list .year-header th{
list-style: none; padding-top: 4rem;
margin: 0;
}
ul.download-list > li li:first-of-type{
margin-top: 1rem;
}
ul.download-list p.header{
font-size: 1.4rem; font-size: 1.4rem;
font-family: "League Spartan", Arial, sans-serif; font-family: "League Spartan", Arial, sans-serif;
margin-top: 4rem; margin-top: 4rem;
@ -742,8 +752,6 @@ ul.download-list p.header{
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--header); color: var(--header);
margin-top: 0;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
@ -2507,6 +2515,14 @@ aside button.close:active{
right: -1px; 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{ @keyframes progress{
0%{ 0%{
background-position: -60px 0px; background-position: -60px 0px;
@ -2735,8 +2751,10 @@ ul.feed p{
} }
@media(max-width: 1200px){ @media(max-width: 1200px){
ul.download-list{ .download-list{
grid-template-columns: 1fr 1fr; overflow-x: scroll;
display: block; /* needed to make overflow work */
width: 100%;
} }
} }
@ -2918,10 +2936,6 @@ ul.feed p{
font-size: 1rem; font-size: 1rem;
margin: 0; margin: 0;
} }
ul.download-list{
grid-template-columns: 1fr;
}
} }
@media(max-width: 730px){ @media(max-width: 730px){

View file

@ -42,10 +42,13 @@ require_once('Core.php');
<p>Get notified of new ebooks in your news client with our <a href="/feeds/atom">Atom</a> or <a href="/feeds/rss">RSS</a> feeds.</p> <p>Get notified of new ebooks in your news client with our <a href="/feeds/atom">Atom</a> or <a href="/feeds/rss">RSS</a> feeds.</p>
</li> </li>
<li> <li>
<p>Parse and process the feeds to use our ebooks in your personal software projects. (Organizations, see <a href="#corporate-sponsors">corporate sponsorship</a> instead.)</p> <p>Parse and process the feeds to use our ebooks in your personal software projects.</p>
</li> </li>
</ul> </ul>
</li> </li>
<li>
<p>Access to <a href="/patrons-circle/downloads">bulk ebook downloads</a> to easily download entire months worth of ebooks at once.</p>
</li>
<li> <li>
<p>The ability to submit a book for inclusion on our <a href="/contribute/wanted-ebooks">Wanted Ebooks list</a>, once per quarter. (Submissions must conform to our <a href="/contribute/collections-policy">collections policy</a> and are subject to approval.)</p> <p>The ability to submit a book for inclusion on our <a href="/contribute/wanted-ebooks">Wanted Ebooks list</a>, once per quarter. (Submissions must conform to our <a href="/contribute/collections-policy">collections policy</a> and are subject to approval.)</p>
</li> </li>
@ -53,6 +56,7 @@ require_once('Core.php');
<p><strong>The right to periodically vote on a selection from our <a href="/contribute/wanted-ebooks">Wanted Ebooks list</a> to choose an ebook for immediate production.</strong> The resulting ebook will be a permanent addition to our <a href="/ebooks">online catalog of free digital literature</a>.</p> <p><strong>The right to periodically vote on a selection from our <a href="/contribute/wanted-ebooks">Wanted Ebooks list</a> to choose an ebook for immediate production.</strong> The resulting ebook will be a permanent addition to our <a href="/ebooks">online catalog of free digital literature</a>.</p>
</li> </li>
</ul> </ul>
<p><em>Membership in the Patrons Circle is limited to individuals only. Organizations, see <a href="#corporate-sponsors">corporate sponsorship</a> instead.</em></p>
<p class="button-row"> <p class="button-row">
<a href="https://fundraising.fracturedatlas.org/standard-ebooks/monthly_support" class="button">Donate $10/month or more</a> <a href="https://fundraising.fracturedatlas.org/standard-ebooks/monthly_support" class="button">Donate $10/month or more</a>
<a href="https://fundraising.fracturedatlas.org/donor_intents/new?donation_intent=cd005756-7327-463d-bd53-a08acc5eaa4a" class="button">Donate $100 or more</a> <a href="https://fundraising.fracturedatlas.org/donor_intents/new?donation_intent=cd005756-7327-463d-bd53-a08acc5eaa4a" class="button">Donate $100 or more</a>
@ -141,6 +145,9 @@ require_once('Core.php');
<li> <li>
<p>Get access to our OPDS, Atom, and RSS <a href="/feeds">ebook feeds</a> for use by your organization for the duration of your sponsorship. We can also produce different kinds of feeds to meet your needs, like <abbr class="acronym">ONIX</abbr> feeds.</p> <p>Get access to our OPDS, Atom, and RSS <a href="/feeds">ebook feeds</a> for use by your organization for the duration of your sponsorship. We can also produce different kinds of feeds to meet your needs, like <abbr class="acronym">ONIX</abbr> feeds.</p>
</li> </li>
<li>
<p>Get access to <a href="/patrons-circle/downloads">bulk ebook downloads</a> to easily download large categories of ebooks, all at once.</p>
</li>
</ul> </ul>
<p>To inquire about sponsorship options, contact the <a href="/about#editor-in-chief">Standard Ebooks Editor-in-Chief</a>.</p> <p>To inquire about sponsorship options, contact the <a href="/about#editor-in-chief">Standard Ebooks Editor-in-Chief</a>.</p>
</section> </section>

View file

@ -151,6 +151,7 @@ catch(Exceptions\InvalidCollectionException $ex){
<a<? if($page < ceil($totalEbooks / $perPage)){ ?> href="/ebooks/?page=<?= $page + 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a> <a<? if($page < ceil($totalEbooks / $perPage)){ ?> href="/ebooks/?page=<?= $page + 1 ?><? if($queryString != ''){ ?>&amp;<?= $queryString ?><? } ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a>
</nav> </nav>
<? } ?> <? } ?>
<p class="feeds-alert">We also have <a href="/patrons-circle/downloads">bulk ebook downloads</a> available, as well as <a href="/feeds">ebook catalog feeds</a> for use directly in your ereader app or RSS reader.</p>
<? if(sizeof($ebooks) > 0 && $query == '' && sizeof($tags) == 0 && $collection === null && $page == 1){ ?> <? if(sizeof($ebooks) > 0 && $query == '' && sizeof($tags) == 0 && $collection === null && $page == 1){ ?>
<?= Template::ContributeAlert() ?> <?= Template::ContributeAlert() ?>
<? } ?> <? } ?>

View file

@ -54,7 +54,7 @@ if($type == 'atom'){
</ul> </ul>
<? } ?> <? } ?>
<? if($type == 'opds'){ ?> <? if($type == 'opds'){ ?>
<p><a href="https://en.wikipedia.org/wiki/Open_Publication_Distribution_System">OPDS feeds</a> are designed for use with ereading apps on your phone or tablet, or with ereading systems like <a href="http://koreader.rocks/">KOreader</a>. Add our OPDS feed to your ereading app to search, browse, and download from our entire catalog, directly in your ereader.</p> <p><a href="https://en.wikipedia.org/wiki/Open_Publication_Distribution_System">OPDS feeds</a>, 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.</p>
<p>Theyre also perfect for scripting, or for libraries or other organizations who wish to download and process our catalog of ebooks.</p> <p>Theyre also perfect for scripting, or for libraries or other organizations who wish to download and process our catalog of ebooks.</p>
<? }elseif($type == 'rss'){ ?> <? }elseif($type == 'rss'){ ?>
<p>RSS feeds are the predecessors of <a href="/feeds/atom">Atom feeds</a>. They contain less information than Atom feeds, but might be better supported by some news readers.</p> <p>RSS feeds are the predecessors of <a href="/feeds/atom">Atom feeds</a>. They contain less information than Atom feeds, but might be better supported by some news readers.</p>

View file

@ -14,7 +14,7 @@ require_once('Core.php');
<?= Template::FeedHowTo() ?> <?= Template::FeedHowTo() ?>
<section id="opds-feeds"> <section id="opds-feeds">
<h2>OPDS 1.2 feeds</h2> <h2>OPDS 1.2 feeds</h2>
<p><a href="https://en.wikipedia.org/wiki/Open_Publication_Distribution_System">OPDS feeds</a>, or “catalogs, can be added to ereading apps on phones and tablets, or to ereading systems like <a href="http://koreader.rocks/">KOreader</a>. Add our OPDS feed to your ereading app to search, browse, and download from our entire catalog, directly in your ereader.</p> <p><a href="https://en.wikipedia.org/wiki/Open_Publication_Distribution_System">OPDS feeds</a>, 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.</p>
<p>Theyre also perfect for scripting, or for libraries or other organizations who wish to download and process our catalog of ebooks.</p> <p>Theyre also perfect for scripting, or for libraries or other organizations who wish to download and process our catalog of ebooks.</p>
<ul class="feed"> <ul class="feed">
<li> <li>

View file

@ -2,96 +2,229 @@
require_once('Core.php'); require_once('Core.php');
use Safe\DateTime; use Safe\DateTime;
use function Safe\apcu_fetch;
use function Safe\filemtime; use function Safe\filemtime;
use function Safe\filesize; use function Safe\filesize;
use function Safe\glob; use function Safe\glob;
use function Safe\gmdate; use function Safe\gmdate;
use function Safe\preg_match;
use function Safe\sort;
use function Safe\rsort; use function Safe\rsort;
use function Safe\usort;
$ex = null; $forbiddenException = null;
if(isset($_SERVER['PHP_AUTH_USER'])){ if(isset($_SERVER['PHP_AUTH_USER'])){
// We get here if the user entered an invalid HTTP Basic Auth username, // We get here if the user entered an invalid HTTP Basic Auth username,
// and this page was served as the 401 page. // 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'); // Process ebooks by year
rsort($files);
$years = []; $years = [];
foreach($files as $file){ try{
$obj = new stdClass(); $years = apcu_fetch('bulk-downloads-years');
$date = new DateTime(str_replace('se-ebooks-', '', basename($file, '.zip')) . '-01'); }
$updated = new DateTime('@' . filemtime($file)); catch(Safe\Exceptions\ApcuException $ex){
$obj->Month = $date->format('F'); // Nothing in the cache, generate the files
$obj->Url = '/patrons-circle/downloads/' . basename($file);
$obj->Size = Formatter::ToFileSize(filesize($file)); $files = glob(WEB_ROOT . '/patrons-circle/downloads/months/*/*.zip');
$obj->Updated = $updated->format('M i'); rsort($files);
// The count of ebooks in each file is stored as a filesystem attribute
$obj->Count = exec('attr -g ebook-count ' . escapeshellarg($file)) ?: null; foreach($files as $file){
if($obj->Count !== null){ $obj = new stdClass();
$obj->Count = intval($obj->Count); $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')){ // Sort the downloads by filename extension
$obj->Updated = $obj->Updated . $updated->format(', Y'); 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
} }
?><?= Template::Header(['title' => 'Bulk Ebook Downloads', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?> ?><?= Template::Header(['title' => 'Bulk Ebook Downloads', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?>
<main> <main>
<section class="narrow bulk-downloads has-hero"> <section class="bulk-downloads has-hero">
<h1>Bulk Ebook Downloads</h1> <h1>Bulk Ebook Downloads</h1>
<picture> <picture>
<source srcset="/images/the-shop-of-the-bookdealer@2x.avif 2x, /images/the-shop-of-the-bookdealer.avif 1x" type="image/avif"/> <source srcset="/images/the-shop-of-the-bookdealer@2x.avif 2x, /images/the-shop-of-the-bookdealer.avif 1x" type="image/avif"/>
<source srcset="/images/the-shop-of-the-bookdealer@2x.jpg 2x, /images/the-shop-of-the-bookdealer.jpg 1x" type="image/jpg"/> <source srcset="/images/the-shop-of-the-bookdealer@2x.jpg 2x, /images/the-shop-of-the-bookdealer.jpg 1x" type="image/jpg"/>
<img src="/images/the-shop-of-the-bookdealer@2x.jpg" alt="A gentleman in regency-era dress buys books from a bookseller."/> <img src="/images/the-shop-of-the-bookdealer@2x.jpg" alt="A gentleman in regency-era dress buys books from a bookseller."/>
</picture> </picture>
<? if($ex !== null){ ?> <? if($forbiddenException !== null){ ?>
<ul class="message error"> <ul class="message error">
<li> <li>
<p><?= Formatter::ToPlainText($ex->getMessage()) ?></p> <p><?= Formatter::ToPlainText($forbiddenException->getMessage()) ?></p>
</li> </li>
</ul> </ul>
<? } ?> <? } ?>
<p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p> <p><a href="/about#patrons-circle">Patrons circle members</a> can download zip files containing all of the ebooks that were released in a given month of Standard Ebooks history. You can <a href="/donate#patrons-circle">join the Patrons Circle</a> with a small donation in support of our continuing mission to create free, beautiful digital literature.</p>
<p>These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook.</p> <p>These zip files contain each ebook in every format we offer, and are updated once daily with the latest versions of each ebook.</p>
<p>If youre a Patrons Circle member, when prompted enter your email address and leave the password blank to download these files.</p> <p>If youre a Patrons Circle member, when prompted enter your email address and leave the password blank to download these files.</p>
<ul class="download-list">
<? foreach($years as $year => $items){ ?> <section id="downloads-by-subject">
<li> <h2>Downloads by subject</h2>
<p class="header"><?= Formatter::ToPlainText((string)$year) ?></p> <table class="download-list">
<table>
<thead> <thead>
<td></td> <tr class="mid-header">
<td>Ebooks</td> <td></td>
<td>Size</td> <th scope="col">Ebooks</th>
<td>Updated</td> <th scope="col">Updated</th>
<th scope="col" colspan="10">Download</th>
</tr>
</thead> </thead>
<tbody> <tbody>
<? foreach($items as $item){ ?> <? foreach($subjects as $subject => $items){ ?>
<tr> <tr>
<td><a download="" href="<?= Formatter::ToPlainText($item->Url) ?>"><?= Formatter::ToPlainText($item->Month) ?></a></td> <td class="row-header"><?= Formatter::ToPlainText($subject) ?></td>
<td><?= Formatter::ToPlainText(number_format($item->Count)) ?></td> <td class="number"><?= Formatter::ToPlainText(number_format($items[0]->Count)) ?></td>
<td><?= Formatter::ToPlainText($item->Size) ?></td> <td class="number"><?= Formatter::ToPlainText($items[0]->UpdatedString) ?></td>
<td><?= Formatter::ToPlainText($item->Updated) ?></td>
<? foreach($items as $item){ ?>
<td class="download"><a href="<?= $item->Url ?>" download=""><?= $item->Type ?></a></td>
<td>(<?= Formatter::ToPlainText($item->Size) ?>)</td>
<? } ?>
</tr> </tr>
<? } ?> <? } ?>
</tbody> </tbody>
</table> </table>
</li> </section>
<? } ?>
</ul> <section id="downloads-by-year">
<h2>Downloads by year</h2>
<table class="download-list">
<tbody>
<? foreach($years as $year => $months){
$yearHeader = Formatter::ToPlainText((string)$year);
?>
<tr class="year-header">
<th colspan="13" scope="colgroup" id="<?= $yearHeader ?>"><?= Formatter::ToPlainText((string)$year) ?></th>
</tr>
<tr class="mid-header">
<td></td>
<th id="<?= $yearHeader ?>-ebooks" scope="col">Ebooks</th>
<th id="<?= $yearHeader ?>-updated" scope="col">Updated</th>
<th id="<?= $yearHeader ?>-download" colspan="10" scope="col">Download</th>
</tr>
<? foreach($months as $month => $items){
$monthHeader = $items[0]->Month;
?>
<tr>
<th class="row-header" headers="<?= $yearHeader ?>" id="<?= $monthHeader ?>"><?= Formatter::ToPlainText($month) ?></th>
<td class="number" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-ebooks"><?= Formatter::ToPlainText(number_format($items[0]->Count)) ?></td>
<td class="number" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-updated"><?= Formatter::ToPlainText($items[0]->UpdatedString) ?></td>
<? foreach($items as $item){ ?>
<td headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-download" class="download"><a href="<?= $item->Url ?>" download=""><?= $item->Type ?></a></td>
<td headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-download">(<?= Formatter::ToPlainText($item->Size) ?>)</td>
<? } ?>
</tr>
<? } ?>
<? } ?>
</tbody>
</table>
</section>
</section> </section>
</main> </main>
<?= Template::Footer() ?> <?= Template::Footer() ?>