diff --git a/.gitignore b/.gitignore
index 2f7ff706..1fd4a446 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ composer.lock
www/manual/*
!www/manual/index.php
config/php/fpm/standardebooks.org-secrets.ini
+www/patrons-circle/downloads/*.zip
diff --git a/scripts/generate-monthly-downloads b/scripts/generate-monthly-downloads
new file mode 100755
index 00000000..72f176a8
--- /dev/null
+++ b/scripts/generate-monthly-downloads
@@ -0,0 +1,97 @@
+#!/usr/bin/php
+
+require_once('/standardebooks.org/web/lib/Core.php');
+
+$longopts = ['webroot:', 'weburl:'];
+$options = getopt('', $longopts);
+$webRoot = $options['webroot'] ?? '/standardebooks.org/web';
+$webUrl = $options['weburl'] ?? 'https://standardebooks.org';
+
+$contentFiles = explode("\n", trim(shell_exec('find ' . escapeshellarg($webRoot . '/www/ebooks/') . ' -name "content.opf" | sort') ?? ''));
+$ebooksByMonth = [];
+$lastUpdatedTimestamps = [];
+
+if(!is_dir(WEB_ROOT . '/patrons-circle/downloads')){
+ mkdir(WEB_ROOT . '/patrons-circle/downloads');
+}
+
+// Iterate over all ebooks and arrange them by publication month
+foreach($contentFiles as $path){
+ if($path == '')
+ continue;
+
+ $ebookWwwFilesystemPath = '';
+
+ try{
+ $ebookWwwFilesystemPath = preg_replace('|/content\.opf|ius', '', $path);
+
+ $ebook = new Ebook($ebookWwwFilesystemPath);
+
+ $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;
+ }
+ }
+ catch(\Exception $ex){
+ print('Failed to generate download for `' . $ebookWwwFilesystemPath . '`. Exception: ' . $ex->getMessage());
+ continue;
+ }
+}
+
+foreach($ebooksByMonth as $month => $ebooks){
+ $filename = 'se-ebooks-' . $month . '.zip';
+ $filePath = $webRoot . '/www/patrons-circle/downloads/' . $filename;
+
+ // If the file doesn't exist, or if the content.opf last updated time is newer than the file creation 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 . '/www' . $ebook->EpubUrl;
+ $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath));
+ }
+
+ if($ebook->Azw3Url !== null){
+ $ebookFilePath = $webRoot . '/www' . $ebook->Azw3Url;
+ $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath));
+ }
+
+ if($ebook->KepubUrl !== null){
+ $ebookFilePath = $webRoot . '/www' . $ebook->KepubUrl;
+ $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath));
+ }
+
+ if($ebook->AdvancedEpubUrl !== null){
+ $ebookFilePath = $webRoot . '/www' . $ebook->AdvancedEpubUrl;
+ $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . basename($ebookFilePath));
+ }
+
+ if($ebook->TextSinglePageUrl !== null){
+ $ebookFilePath = $webRoot . '/www' . $ebook->TextSinglePageUrl . '.xhtml';
+ $zip->addFile($ebookFilePath, $ebook->UrlSafeIdentifier . '/' . str_replace('single-page', $ebook->UrlSafeIdentifier, basename($ebookFilePath)));
+ }
+ }
+
+ $zip->close();
+
+ rename($tempFilename, $filePath);
+ }
+}
diff --git a/templates/Footer.php b/templates/Footer.php
index 342e2bb1..4435f112 100644
--- a/templates/Footer.php
+++ b/templates/Footer.php
@@ -19,6 +19,9 @@
GitHub
+
+ Bulk downloads
+
Ebook Feeds
diff --git a/www/css/core.css b/www/css/core.css
index 41ad7677..75119b56 100644
--- a/www/css/core.css
+++ b/www/css/core.css
@@ -246,6 +246,7 @@ main > section.narrow > *{
main > section.narrow > h1,
main > section.narrow > hgroup,
main > section.narrow.has-hero > picture{
+ box-sizing: border-box;
max-width: none;
}
@@ -671,10 +672,49 @@ ul.message.error li:only-child{
margin-left: 0;
}
+.bulk-downloads > p{
+ width: 100%;
+ max-width: 40rem;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.bulk-downloads i{
+ color: var(--sub-text);
+}
+
+.bulk-downloads > ul{
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 4rem;
+ list-style: none;
+ margin-top: 4rem;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.bulk-downloads > ul > li{
+ margin: 0;
+}
+
+.bulk-downloads ul ul{
+ list-style: none;
+ margin: 0;
+}
+
+.bulk-downloads > ul > li li:first-of-type{
+ margin-top: 1rem;
+}
+
+.bulk-downloads h2{
+ margin-top: 0;
+}
+
.ebooks nav li.highlighted a:focus,
a.button:focus,
input[type="email"]:focus,
input[type="text"]:focus,
+input[type="month"]:focus,
input[type="search"]:focus,
label.checkbox:focus-within,
select:focus,
@@ -1199,6 +1239,11 @@ footer > p{
display: inlie-flex;
align-items: center;
}
+
+footer > p:first-child{
+ font-size: .9rem;
+}
+
footer > p:first-child::before{
font-family: "Fork Awesome";
content: "\f0e0";
@@ -1571,6 +1616,7 @@ label.search{
select,
input[type="text"],
+input[type="month"],
input[type="email"],
input[type="search"]{
-webkit-appearance: none;
@@ -1674,6 +1720,7 @@ nav a[rel],
a.button,
button,
input[type="text"],
+input[type="month"],
input[type="email"],
input[type="search"],
select{
@@ -1689,6 +1736,8 @@ button:hover{
input[type="text"]:focus,
input[type="text"]:hover,
+input[type="month"]:focus,
+input[type="month"]:hover,
input[type="email"]:focus,
input[type="email"]:hover,
input[type="search"]:focus,
@@ -1701,6 +1750,7 @@ select:hover{
}
input[type="text"]:user-invalid,
+input[type="month"]:user-invalid,
input[type="email"]:user-invalid,
input[type="search"]:user-invalid{
border-color: #ff0000;
@@ -1708,6 +1758,7 @@ input[type="search"]:user-invalid{
}
input[type="text"]:-moz-ui-invalid,
+input[type="month"]:-moz-ui-invalid,
input[type="email"]:-moz-ui-invalid,
input[type="search"]:-moz-ui-invalid{
border-color: #ff0000;
@@ -2824,6 +2875,12 @@ ul.feed p{
}
}
+@media(max-width: 800px){
+ .bulk-downloads > ul{
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
@media(max-width: 730px){
form[action="/ebooks"]{
grid-template-columns: auto auto 1fr 1fr;
@@ -3119,6 +3176,10 @@ ul.feed p{
.votes tr + tr{
margin-top: 2rem;
}
+
+ .bulk-downloads > ul{
+ grid-template-columns: 1fr;
+ }
}
@media(max-width: 470px){
@@ -3281,6 +3342,7 @@ ul.feed p{
a.button,
button,
input[type="text"],
+ input[type="month"],
input[type="email"],
input[type="search"],
select,
@@ -3290,6 +3352,8 @@ ul.feed p{
button:hover,
input[type="text"]:focus,
input[type="text"]:hover,
+ input[type="month"]:focus,
+ input[type="month"]:hover,
input[type="email"]:focus,
input[type="email"]:hover,
input[type="search"]:focus,
diff --git a/www/css/dark.css b/www/css/dark.css
index 84df020c..dfa21fe9 100644
--- a/www/css/dark.css
+++ b/www/css/dark.css
@@ -55,6 +55,7 @@ article.ebook section aside.donation{
select,
input[type="text"],
+input[type="month"],
input[type="email"],
input[type="search"]{
box-shadow: 1px 1px 0 rgba(0, 0, 0, .5) inset;
diff --git a/www/images/still-life-with-books.avif b/www/images/still-life-with-books.avif
new file mode 100644
index 00000000..6a7ea0f1
Binary files /dev/null and b/www/images/still-life-with-books.avif differ
diff --git a/www/images/still-life-with-books.jpg b/www/images/still-life-with-books.jpg
new file mode 100644
index 00000000..6e7e959b
Binary files /dev/null and b/www/images/still-life-with-books.jpg differ
diff --git a/www/images/still-life-with-books@2x.avif b/www/images/still-life-with-books@2x.avif
new file mode 100644
index 00000000..cda6b502
Binary files /dev/null and b/www/images/still-life-with-books@2x.avif differ
diff --git a/www/images/still-life-with-books@2x.jpg b/www/images/still-life-with-books@2x.jpg
new file mode 100644
index 00000000..085243c4
Binary files /dev/null and b/www/images/still-life-with-books@2x.jpg differ
diff --git a/www/images/the-shop-of-the-bookdealer.avif b/www/images/the-shop-of-the-bookdealer.avif
new file mode 100644
index 00000000..a3b10f2b
Binary files /dev/null and b/www/images/the-shop-of-the-bookdealer.avif differ
diff --git a/www/images/the-shop-of-the-bookdealer.jpg b/www/images/the-shop-of-the-bookdealer.jpg
new file mode 100644
index 00000000..cede7a52
Binary files /dev/null and b/www/images/the-shop-of-the-bookdealer.jpg differ
diff --git a/www/images/the-shop-of-the-bookdealer@2x.avif b/www/images/the-shop-of-the-bookdealer@2x.avif
new file mode 100644
index 00000000..32d3d178
Binary files /dev/null and b/www/images/the-shop-of-the-bookdealer@2x.avif differ
diff --git a/www/images/the-shop-of-the-bookdealer@2x.jpg b/www/images/the-shop-of-the-bookdealer@2x.jpg
new file mode 100644
index 00000000..7d63ad12
Binary files /dev/null and b/www/images/the-shop-of-the-bookdealer@2x.jpg differ
diff --git a/www/patrons-circle/downloads/index.php b/www/patrons-circle/downloads/index.php
new file mode 100644
index 00000000..eb61c6e3
--- /dev/null
+++ b/www/patrons-circle/downloads/index.php
@@ -0,0 +1,58 @@
+
+require_once('Core.php');
+
+$files = glob(WEB_ROOT . '/patrons-circle/downloads/*.zip');
+rsort($files);
+
+$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->Updated = $updated->format('M i');
+
+ if($updated->format('Y') != gmdate('Y')){
+ $obj->Updated = $obj->Updated . $updated->format(', Y');
+ }
+
+ $year = $date->format('Y');
+
+ if(!isset($years[$year])){
+ $years[$year] = [];
+ }
+
+ $years[$year][] = $obj;
+}
+
+?>= Template::Header(['title' => 'Download Ebooks by Month', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?>
+
+
+ Download Ebooks by Month
+
+
+
+
+
+ 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 no password to download these files.
+
+ foreach($years as $year => $items){ ?>
+
+
+ = Formatter::ToPlainText($year) ?>
+
+
+
+ } ?>
+
+
+
+= Template::Footer() ?>