mirror of
https://github.com/standardebooks/web.git
synced 2025-07-13 10:02:02 -04:00
Break bulk downloads into sections, add authors bulk download, and refactor bulk download generation code
This commit is contained in:
parent
7f50f00b42
commit
fc1db3a3d4
12 changed files with 355 additions and 206 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -14,6 +14,4 @@ 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/bulk-downloads/months
|
www/bulk-downloads/**/*.zip
|
||||||
www/bulk-downloads/subjects
|
|
||||||
www/bulk-downloads/collections
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<?
|
<?
|
||||||
|
|
||||||
|
use function Safe\preg_replace;
|
||||||
|
|
||||||
class Collection{
|
class Collection{
|
||||||
public $Name;
|
public $Name;
|
||||||
public $Url;
|
public $Url;
|
||||||
|
@ -10,4 +12,8 @@ class Collection{
|
||||||
$this->Name = $name;
|
$this->Name = $name;
|
||||||
$this->Url = '/collections/' . Formatter::MakeUrlSafe($this->Name);
|
$this->Url = '/collections/' . Formatter::MakeUrlSafe($this->Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function GetSortedName(): string{
|
||||||
|
return preg_replace('/^(the|and|a|)\b/ius', '', $this->Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
126
lib/Library.php
126
lib/Library.php
|
@ -225,33 +225,50 @@ class Library{
|
||||||
return $ebooks;
|
return $ebooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function FillBulkDownloadObject(string $file, string $downloadType): stdClass{
|
private static function FillBulkDownloadObject(string $dir, string $downloadType): stdClass{
|
||||||
$obj = new 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
|
// The count of ebooks in each file is stored as a filesystem attribute
|
||||||
$obj->Count = exec('attr -g se-ebook-count ' . escapeshellarg($file)) ?: null;
|
$obj->EbookCount = exec('attr -g se-ebook-count ' . escapeshellarg($dir)) ?: null;
|
||||||
if($obj->Count !== null){
|
if($obj->EbookCount == null){
|
||||||
$obj->Count = intval($obj->Count);
|
$obj->EbookCount = 0;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$obj->EbookCount = intval($obj->EbookCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The subject of the batch is stored as a filesystem attribute
|
// The subject of the batch is stored as a filesystem attribute
|
||||||
$obj->Label = exec('attr -g se-label ' . escapeshellarg($file)) ?: null;
|
$obj->Label = exec('attr -g se-label ' . escapeshellarg($dir)) ?: null;
|
||||||
if($obj->Label === null){
|
if($obj->Label === null){
|
||||||
$obj->Label = str_replace('se-ebooks-', '', basename($file, '.zip'));
|
$obj->Label = basename($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
$obj->UrlLabel = Formatter::MakeUrlSafe($obj->Label);
|
$obj->UrlLabel = Formatter::MakeUrlSafe($obj->Label);
|
||||||
|
|
||||||
$obj->Url = '/bulk-downloads/' . $downloadType . '/' . $obj->UrlLabel . '/' . basename($file);
|
$obj->LabelSort = exec('attr -g se-label-sort ' . escapeshellarg($dir)) ?: null;
|
||||||
|
if($obj->LabelSort === null){
|
||||||
// The type of ebook in the zip is stored as a filesystem attribute
|
$obj->LabelSort = basename($dir);
|
||||||
$obj->Type = exec('attr -g se-ebook-type ' . escapeshellarg($file));
|
|
||||||
if($obj->Type == 'epub-advanced'){
|
|
||||||
$obj->Type = 'epub (advanced)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$obj->ZipFiles = [];
|
||||||
|
|
||||||
|
$files = glob($dir . '/*.zip');
|
||||||
|
foreach($files as $file){
|
||||||
|
$zipFile = new stdClass();
|
||||||
|
$zipFile->Size = Formatter::ToFileSize(filesize($file));
|
||||||
|
|
||||||
|
$zipFile->Url = '/bulk-downloads/' . $downloadType . '/' . $obj->UrlLabel . '/' . basename($file);
|
||||||
|
|
||||||
|
// The type of ebook in the zip is stored as a filesystem attribute
|
||||||
|
$zipFile->Type = exec('attr -g se-ebook-type ' . escapeshellarg($file));
|
||||||
|
if($zipFile->Type == 'epub-advanced'){
|
||||||
|
$zipFile->Type = 'epub (advanced)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$obj->ZipFiles[] = $zipFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
$obj->Updated = new DateTime('@' . filemtime($files[0]));
|
||||||
$obj->UpdatedString = $obj->Updated->format('M j');
|
$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)
|
// Add a period to the abbreviated month, but not if it's May (the only 3-letter month)
|
||||||
$obj->UpdatedString = preg_replace('/^(.+?)(?<!May) /', '\1. ', $obj->UpdatedString);
|
$obj->UpdatedString = preg_replace('/^(.+?)(?<!May) /', '\1. ', $obj->UpdatedString);
|
||||||
|
@ -259,9 +276,16 @@ class Library{
|
||||||
$obj->UpdatedString = $obj->Updated->format('M j, Y');
|
$obj->UpdatedString = $obj->Updated->format('M j, Y');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort the downloads by filename extension
|
||||||
|
$obj->ZipFiles = self::SortBulkDownloads($obj->ZipFiles);
|
||||||
|
|
||||||
return $obj;
|
return $obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, stdClass> $items
|
||||||
|
* @return array<string, array<int|string, array<int|string, mixed>>>
|
||||||
|
*/
|
||||||
private static function SortBulkDownloads(array $items): array{
|
private static function SortBulkDownloads(array $items): array{
|
||||||
// This sorts our items in a special order, epub first and advanced epub last
|
// This sorts our items in a special order, epub first and advanced epub last
|
||||||
$result = [];
|
$result = [];
|
||||||
|
@ -295,13 +319,17 @@ class Library{
|
||||||
public static function RebuildBulkDownloadsCache(): array{
|
public static function RebuildBulkDownloadsCache(): array{
|
||||||
$years = [];
|
$years = [];
|
||||||
$subjects = [];
|
$subjects = [];
|
||||||
|
$collections = [];
|
||||||
|
$authors = [];
|
||||||
|
|
||||||
// Generate bulk downloads by month
|
// Generate bulk downloads by month
|
||||||
$files = glob(WEB_ROOT . '/bulk-downloads/months/*/*.zip');
|
// These get special treatment because they're sorted by two dimensions,
|
||||||
rsort($files);
|
// year and month.
|
||||||
|
$dirs = glob(WEB_ROOT . '/bulk-downloads/months/*/', GLOB_NOSORT);
|
||||||
|
rsort($dirs);
|
||||||
|
|
||||||
foreach($files as $file){
|
foreach($dirs as $dir){
|
||||||
$obj = self::FillBulkDownloadObject($file, 'months');
|
$obj = self::FillBulkDownloadObject($dir, 'months');
|
||||||
|
|
||||||
$date = new DateTime($obj->Label . '-01');
|
$date = new DateTime($obj->Label . '-01');
|
||||||
$year = $date->format('Y');
|
$year = $date->format('Y');
|
||||||
|
@ -311,64 +339,36 @@ class Library{
|
||||||
$years[$year] = [];
|
$years[$year] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!isset($years[$year][$month])){
|
$years[$year][$month] = $obj;
|
||||||
$years[$year][$month] = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$years[$year][$month][] = $obj;
|
apcu_store('bulk-downloads-years', $years, 43200); // 12 hours
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the downloads by filename extension
|
|
||||||
foreach($years as $year => $months){
|
|
||||||
foreach($months as $month => $items){
|
|
||||||
$years[$year][$month] = self::SortBulkDownloads($items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apcu_store('bulk-downloads-years', $years);
|
|
||||||
|
|
||||||
// Generate bulk downloads by subject
|
// Generate bulk downloads by subject
|
||||||
$files = glob(WEB_ROOT . '/bulk-downloads/subjects/*/*.zip');
|
foreach(glob(WEB_ROOT . '/bulk-downloads/subjects/*/', GLOB_NOSORT) as $dir){
|
||||||
sort($files);
|
$subjects[] = self::FillBulkDownloadObject($dir, 'subjects');
|
||||||
|
|
||||||
foreach($files as $file){
|
|
||||||
$obj = self::FillBulkDownloadObject($file, 'subjects');
|
|
||||||
|
|
||||||
if(!isset($subjects[$obj->UrlLabel])){
|
|
||||||
$subjects[$obj->UrlLabel] = [];
|
|
||||||
}
|
}
|
||||||
|
usort($subjects, function($a, $b){ return $a->LabelSort <=> $b->LabelSort; });
|
||||||
|
|
||||||
$subjects[$obj->UrlLabel][] = $obj;
|
apcu_store('bulk-downloads-subjects', $subjects, 43200); // 12 hours
|
||||||
}
|
|
||||||
|
|
||||||
foreach($subjects as $subject => $items){
|
|
||||||
$subjects[$subject] = self::SortBulkDownloads($items);
|
|
||||||
}
|
|
||||||
|
|
||||||
apcu_store('bulk-downloads-subjects', $subjects);
|
|
||||||
|
|
||||||
|
|
||||||
// Generate bulk downloads by collection
|
// Generate bulk downloads by collection
|
||||||
$files = glob(WEB_ROOT . '/bulk-downloads/collections/*/*.zip');
|
foreach(glob(WEB_ROOT . '/bulk-downloads/collections/*/', GLOB_NOSORT) as $dir){
|
||||||
sort($files);
|
$collections[] = self::FillBulkDownloadObject($dir, 'collections');
|
||||||
|
|
||||||
foreach($files as $file){
|
|
||||||
$obj = self::FillBulkDownloadObject($file, 'collections');
|
|
||||||
|
|
||||||
if(!isset($collections[$obj->UrlLabel])){
|
|
||||||
$collections[$obj->UrlLabel] = [];
|
|
||||||
}
|
}
|
||||||
|
usort($collections, function($a, $b){ return $a->LabelSort <=> $b->LabelSort; });
|
||||||
|
|
||||||
$collections[$obj->UrlLabel][] = $obj;
|
apcu_store('bulk-downloads-collections', $collections, 43200); // 12 hours
|
||||||
|
|
||||||
|
// Generate bulk downloads by authors
|
||||||
|
foreach(glob(WEB_ROOT . '/bulk-downloads/authors/*/', GLOB_NOSORT) as $dir){
|
||||||
|
$authors[] = self::FillBulkDownloadObject($dir, 'authors');
|
||||||
}
|
}
|
||||||
|
usort($authors, function($a, $b){ return $a->LabelSort <=> $b->LabelSort; });
|
||||||
|
|
||||||
foreach($collections as $collection => $items){
|
apcu_store('bulk-downloads-authors', $authors, 43200); // 12 hours
|
||||||
$collections[$collection] = self::SortBulkDownloads($items);
|
|
||||||
}
|
|
||||||
|
|
||||||
apcu_store('bulk-downloads-collections', $collections);
|
return ['years' => $years, 'subjects' => $subjects, 'collections' => $collections, 'authors' => $authors];
|
||||||
|
|
||||||
return ['years' => $years, 'subjects' => $subjects, 'collections' => $collections];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function RebuildCache(): void{
|
public static function RebuildCache(): void{
|
||||||
|
|
|
@ -8,15 +8,12 @@ $longopts = ['webroot:'];
|
||||||
$options = getopt('', $longopts);
|
$options = getopt('', $longopts);
|
||||||
$webRoot = $options['webroot'] ?? WEB_ROOT;
|
$webRoot = $options['webroot'] ?? WEB_ROOT;
|
||||||
|
|
||||||
$ebooksByMonth = [];
|
$types = ['epub', 'epub-advanced', 'azw3', 'kepub', 'xhtml'];
|
||||||
$lastUpdatedTimestampsByMonth = [];
|
$groups = ['collections', 'subjects', 'authors', 'months'];
|
||||||
$subjects = [];
|
$ebooksByGroup = [];
|
||||||
$collections = [];
|
$updatedByGroup = [];
|
||||||
$ebooksBySubject = [];
|
|
||||||
$lastUpdatedTimestampsBySubject = [];
|
|
||||||
$lastUpdatedTimestampsByCollection = [];
|
|
||||||
|
|
||||||
function CreateZip(string $filePath, array $ebooks, string $type, string $webRoot, string $label): void{
|
function CreateZip(string $filePath, array $ebooks, string $type, string $webRoot): void{
|
||||||
$tempFilename = tempnam(sys_get_temp_dir(), "se-ebooks");
|
$tempFilename = tempnam(sys_get_temp_dir(), "se-ebooks");
|
||||||
|
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
|
@ -59,20 +56,9 @@ function CreateZip(string $filePath, array $ebooks, string $type, string $webRoo
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
$dir = dirname($filePath);
|
|
||||||
if(!is_dir($dir)){
|
|
||||||
mkdir($dir, 0775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
rename($tempFilename, $filePath);
|
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));
|
exec('attr -q -s se-ebook-type -V ' . escapeshellarg($type) . ' ' . escapeshellarg($filePath));
|
||||||
|
|
||||||
exec('attr -q -s se-label -V ' . escapeshellarg($label) . ' ' . escapeshellarg($filePath));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate over all ebooks and arrange them by publication month
|
// Iterate over all ebooks and arrange them by publication month
|
||||||
|
@ -80,93 +66,103 @@ foreach(Library::GetEbooksFromFilesystem($webRoot) as $ebook){
|
||||||
$timestamp = $ebook->Created->format('Y-m');
|
$timestamp = $ebook->Created->format('Y-m');
|
||||||
$updatedTimestamp = $ebook->Updated->getTimestamp();
|
$updatedTimestamp = $ebook->Updated->getTimestamp();
|
||||||
|
|
||||||
if(!isset($ebooksByMonth[$timestamp])){
|
|
||||||
$ebooksByMonth[$timestamp] = [];
|
|
||||||
$lastUpdatedTimestampsByMonth[$timestamp] = $updatedTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to the 'ebooks by month' list
|
// Add to the 'ebooks by month' list
|
||||||
$ebooksByMonth[$timestamp][] = $ebook;
|
if(!isset($ebooksByGroup['months'][$timestamp])){
|
||||||
|
$obj = new stdClass();
|
||||||
|
$obj->Label = $timestamp;
|
||||||
|
$obj->LabelSort = $timestamp;
|
||||||
|
$obj->Updated = $updatedTimestamp;
|
||||||
|
$obj->Ebooks = [$ebook];
|
||||||
|
|
||||||
if($updatedTimestamp > $lastUpdatedTimestampsByMonth[$timestamp]){
|
$ebooksByGroup['months'][$timestamp] = $obj;
|
||||||
$lastUpdatedTimestampsByMonth[$timestamp] = $updatedTimestamp;
|
}
|
||||||
|
else{
|
||||||
|
$ebooksByGroup['months'][$timestamp]->Ebooks[] = $ebook;
|
||||||
|
if($updatedTimestamp > $ebooksByGroup['months'][$timestamp]->Updated){
|
||||||
|
$ebooksByGroup['months'][$timestamp]->Updated = $updatedTimestamp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to the 'books by subject' list
|
// Add to the 'books by subject' list
|
||||||
foreach($ebook->Tags as $tag){
|
foreach($ebook->Tags as $tag){
|
||||||
// Add the book's subjects to the main subjects list
|
if(!isset($ebooksByGroup['subjects'][$tag->Name])){
|
||||||
if(!in_array($tag->Name, $subjects)){
|
$obj = new stdClass();
|
||||||
$subjects[] = $tag->Name;
|
$obj->Label = $tag->Name;
|
||||||
$lastUpdatedTimestampsBySubject[$tag->Name] = $updatedTimestamp;
|
$obj->LabelSort = $tag->Name;
|
||||||
|
$obj->Updated = $updatedTimestamp;
|
||||||
|
$obj->Ebooks = [$ebook];
|
||||||
|
|
||||||
|
$ebooksByGroup['subjects'][$tag->Name] = $obj;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$ebooksByGroup['subjects'][$tag->Name]->Ebooks[] = $ebook;
|
||||||
|
if($updatedTimestamp > $ebooksByGroup['subjects'][$tag->Name]->Updated){
|
||||||
|
$ebooksByGroup['subjects'][$tag->Name]->Updated = $updatedTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort this ebook by subject
|
|
||||||
$ebooksBySubject[$tag->Name][] = $ebook;
|
|
||||||
|
|
||||||
if($updatedTimestamp > $lastUpdatedTimestampsBySubject[$tag->Name]){
|
|
||||||
$lastUpdatedTimestampsBySubject[$tag->Name] = $updatedTimestamp;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to the 'books by collection' list
|
// Add to the 'books by collection' list
|
||||||
foreach($ebook->Collections as $collection){
|
foreach($ebook->Collections as $collection){
|
||||||
// Add the book's subjects to the main subjects list
|
if(!isset($ebooksByGroup['collections'][$collection->Name])){
|
||||||
if(!in_array($collection->Name, $collections)){
|
$obj = new stdClass();
|
||||||
$collections[] = $collection->Name;
|
$obj->Label = $collection->Name;
|
||||||
$lastUpdatedTimestampsByCollection[$collection->Name] = $updatedTimestamp;
|
$obj->LabelSort = $collection->GetSortedName();
|
||||||
|
$obj->Updated = $updatedTimestamp;
|
||||||
|
$obj->Ebooks = [$ebook];
|
||||||
|
|
||||||
|
$ebooksByGroup['collections'][$collection->Name] = $obj;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$ebooksByGroup['collections'][$collection->Name]->Ebooks[] = $ebook;
|
||||||
|
if($updatedTimestamp > $ebooksByGroup['collections'][$collection->Name]->Updated){
|
||||||
|
$ebooksByGroup['collections'][$collection->Name]->Updated = $updatedTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort this ebook by subject
|
// Add to the 'books by author' list
|
||||||
$ebooksByCollection[$collection->Name][] = $ebook;
|
foreach($ebook->Authors as $author){
|
||||||
|
if(!isset($ebooksByGroup['authors'][$author->Name])){
|
||||||
|
$obj = new stdClass();
|
||||||
|
$obj->Label = $author->Name;
|
||||||
|
$obj->LabelSort = $author->SortName;
|
||||||
|
$obj->Updated = $updatedTimestamp;
|
||||||
|
$obj->Ebooks = [$ebook];
|
||||||
|
|
||||||
if($updatedTimestamp > $lastUpdatedTimestampsByCollection[$collection->Name]){
|
$ebooksByGroup['authors'][$author->Name] = $obj;
|
||||||
$lastUpdatedTimestampsByCollection[$collection->Name] = $updatedTimestamp;
|
}
|
||||||
|
else{
|
||||||
|
$ebooksByGroup['authors'][$author->Name]->Ebooks[] = $ebook;
|
||||||
|
if($updatedTimestamp > $ebooksByGroup['authors'][$author->Name]->Updated){
|
||||||
|
$ebooksByGroup['authors'][$author->Name]->Updated = $updatedTimestamp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$types = ['epub', 'epub-advanced', 'azw3', 'kepub', 'xhtml'];
|
foreach($groups as $group){
|
||||||
|
foreach($ebooksByGroup[$group] as $collection){
|
||||||
|
$urlSafeCollection = Formatter::MakeUrlSafe($collection->Label);
|
||||||
|
$parentDir = $webRoot . '/bulk-downloads/' . $group . '/' . $urlSafeCollection;
|
||||||
|
|
||||||
|
if(!is_dir($parentDir)){
|
||||||
|
mkdir($parentDir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec('attr -q -s se-ebook-count -V ' . escapeshellarg(sizeof($collection->Ebooks)) . ' ' . escapeshellarg($parentDir));
|
||||||
|
exec('attr -q -s se-label -V ' . escapeshellarg($collection->Label) . ' ' . escapeshellarg($parentDir));
|
||||||
|
exec('attr -q -s se-label-sort -V ' . escapeshellarg($collection->LabelSort) . ' ' . escapeshellarg($parentDir));
|
||||||
|
|
||||||
foreach($ebooksByMonth as $month => $ebooks){
|
|
||||||
foreach($types as $type){
|
foreach($types as $type){
|
||||||
$filename = 'se-ebooks-' . $month . '-' . $type . '.zip';
|
$filePath = $parentDir . '/se-ebooks-' . $urlSafeCollection . '-' . $type . '.zip';
|
||||||
$filePath = $webRoot . '/bulk-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 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]){
|
if(!file_exists($filePath) || filemtime($filePath) < $collection->Updated){
|
||||||
print('Creating ' . $filePath . "\n");
|
print('Creating ' . $filePath . "\n");
|
||||||
|
|
||||||
CreateZip($filePath, $ebooks, $type, $webRoot, $month);
|
CreateZip($filePath, $collection->Ebooks, $type, $webRoot);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach($ebooksBySubject as $subject => $ebooks){
|
|
||||||
foreach($types as $type){
|
|
||||||
$urlSafeSubject = Formatter::MakeUrlSafe($subject);
|
|
||||||
$filename = 'se-ebooks-' . $urlSafeSubject . '-' . $type . '.zip';
|
|
||||||
$filePath = $webRoot . '/bulk-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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,13 +8,13 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<? foreach($collections as $collection => $items){ ?>
|
<? foreach($collections as $collection){ ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="row-header"><a href="/collections/<?= Formatter::MakeUrlSafe($items[0]->Label) ?>"><?= Formatter::ToPlainText($items[0]->Label) ?></a></td>
|
<td class="row-header"><a href="/collections/<?= Formatter::MakeUrlSafe($collection->Label) ?>"><?= Formatter::ToPlainText($collection->Label) ?></a></td>
|
||||||
<td class="number"><?= Formatter::ToPlainText(number_format($items[0]->Count)) ?></td>
|
<td class="number"><?= Formatter::ToPlainText(number_format($collection->EbookCount)) ?></td>
|
||||||
<td class="number"><?= Formatter::ToPlainText($items[0]->UpdatedString) ?></td>
|
<td class="number"><?= Formatter::ToPlainText($collection->UpdatedString) ?></td>
|
||||||
|
|
||||||
<? foreach($items as $item){ ?>
|
<? foreach($collection->ZipFiles as $item){ ?>
|
||||||
<td class="download"><a href="<?= $item->Url ?>" download=""><?= $item->Type ?></a></td>
|
<td class="download"><a href="<?= $item->Url ?>" download=""><?= $item->Type ?></a></td>
|
||||||
<td>(<?= Formatter::ToPlainText($item->Size) ?>)</td>
|
<td>(<?= Formatter::ToPlainText($item->Size) ?>)</td>
|
||||||
<? } ?>
|
<? } ?>
|
||||||
|
|
37
www/bulk-downloads/authors/index.php
Normal file
37
www/bulk-downloads/authors/index.php
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?
|
||||||
|
require_once('Core.php');
|
||||||
|
|
||||||
|
use function Safe\apcu_fetch;
|
||||||
|
|
||||||
|
$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.
|
||||||
|
$forbiddenException = new Exceptions\InvalidPatronException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$authors = [];
|
||||||
|
|
||||||
|
try{
|
||||||
|
$authors = apcu_fetch('bulk-downloads-authors');
|
||||||
|
}
|
||||||
|
catch(Safe\Exceptions\ApcuException $ex){
|
||||||
|
$result = Library::RebuildBulkDownloadsCache();
|
||||||
|
$authors = $result['authors'];
|
||||||
|
}
|
||||||
|
|
||||||
|
?><?= Template::Header(['title' => 'Downloads by Author', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks by a given author.']) ?>
|
||||||
|
<main>
|
||||||
|
<section class="bulk-downloads">
|
||||||
|
<h1>Downloads by Author</h1>
|
||||||
|
<? if($forbiddenException !== null){ ?>
|
||||||
|
<?= Template::Error(['exception' => $forbiddenException]) ?>
|
||||||
|
<? } ?>
|
||||||
|
<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>If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
|
||||||
|
<?= Template::BulkDownloadTable(['label' => 'Author', 'collections' => $authors]); ?>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?= Template::Footer() ?>
|
37
www/bulk-downloads/collections/index.php
Normal file
37
www/bulk-downloads/collections/index.php
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?
|
||||||
|
require_once('Core.php');
|
||||||
|
|
||||||
|
use function Safe\apcu_fetch;
|
||||||
|
|
||||||
|
$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.
|
||||||
|
$forbiddenException = new Exceptions\InvalidPatronException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$collections = [];
|
||||||
|
|
||||||
|
try{
|
||||||
|
$collections = apcu_fetch('bulk-downloads-collections');
|
||||||
|
}
|
||||||
|
catch(Safe\Exceptions\ApcuException $ex){
|
||||||
|
$result = Library::RebuildBulkDownloadsCache();
|
||||||
|
$collections = $result['collections'];
|
||||||
|
}
|
||||||
|
|
||||||
|
?><?= Template::Header(['title' => 'Downloads by Collection', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks in a given collection.']) ?>
|
||||||
|
<main>
|
||||||
|
<section class="bulk-downloads">
|
||||||
|
<h1>Downloads by Collection</h1>
|
||||||
|
<? if($forbiddenException !== null){ ?>
|
||||||
|
<?= Template::Error(['exception' => $forbiddenException]) ?>
|
||||||
|
<? } ?>
|
||||||
|
<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>If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
|
||||||
|
<?= Template::BulkDownloadTable(['label' => 'Collection', 'collections' => $collections]); ?>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?= Template::Footer() ?>
|
|
@ -1,7 +1,6 @@
|
||||||
<?
|
<?
|
||||||
require_once('Core.php');
|
require_once('Core.php');
|
||||||
|
|
||||||
use Safe\DateTime;
|
|
||||||
use function Safe\apcu_fetch;
|
use function Safe\apcu_fetch;
|
||||||
|
|
||||||
$forbiddenException = null;
|
$forbiddenException = null;
|
||||||
|
@ -15,22 +14,25 @@ if(isset($_SERVER['PHP_AUTH_USER'])){
|
||||||
$years = [];
|
$years = [];
|
||||||
$subjects = [];
|
$subjects = [];
|
||||||
$collections = [];
|
$collections = [];
|
||||||
|
$authors = [];
|
||||||
|
|
||||||
try{
|
try{
|
||||||
$years = apcu_fetch('bulk-downloads-years');
|
$years = apcu_fetch('bulk-downloads-years');
|
||||||
$subjects = apcu_fetch('bulk-downloads-subjects');
|
$subjects = apcu_fetch('bulk-downloads-subjects');
|
||||||
$collections = apcu_fetch('bulk-downloads-collections');
|
$collections = apcu_fetch('bulk-downloads-collections');
|
||||||
|
$authors = apcu_fetch('bulk-downloads-authors');
|
||||||
}
|
}
|
||||||
catch(Safe\Exceptions\ApcuException $ex){
|
catch(Safe\Exceptions\ApcuException $ex){
|
||||||
$result = Library::RebuildBulkDownloadsCache();
|
$result = Library::RebuildBulkDownloadsCache();
|
||||||
$years = $result['years'];
|
$years = $result['years'];
|
||||||
$subjects = $result['subjects'];
|
$subjects = $result['subjects'];
|
||||||
$collections = $result['collections'];
|
$collections = $result['collections'];
|
||||||
|
$authors = $result['authors'];
|
||||||
}
|
}
|
||||||
|
|
||||||
?><?= 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="bulk-downloads has-hero">
|
<section class="narrow 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"/>
|
||||||
|
@ -41,54 +43,20 @@ catch(Safe\Exceptions\ApcuException $ex){
|
||||||
<?= Template::Error(['exception' => $forbiddenException]) ?>
|
<?= Template::Error(['exception' => $forbiddenException]) ?>
|
||||||
<? } ?>
|
<? } ?>
|
||||||
<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>
|
<ul>
|
||||||
<p>If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
|
<li>
|
||||||
|
<p><a href="/bulk-downloads/subjects">Downloads by subject</a></p>
|
||||||
<section id="downloads-by-subject">
|
</li>
|
||||||
<h2>Downloads by subject</h2>
|
<li>
|
||||||
<?= Template::BulkDownloadTable(['label' => 'Subject', 'collections' => $subjects]); ?>
|
<p><a href="/bulk-downloads/collections">Downloads by collection</a></p>
|
||||||
</section>
|
</li>
|
||||||
|
<li>
|
||||||
<section id="downloads-by-collection">
|
<p><a href="/bulk-downloads/authors">Downloads by author</a></p>
|
||||||
<h2>Downloads by collection</h2>
|
</li>
|
||||||
<?= Template::BulkDownloadTable(['label' => 'Collection', 'collections' => $collections]); ?>
|
<li>
|
||||||
</section>
|
<p><a href="/bulk-downloads/months">Downloads by month</a></p>
|
||||||
|
</li>
|
||||||
<section id="downloads-by-year">
|
</ul>
|
||||||
<h2>Downloads by month</h2>
|
|
||||||
<table class="download-list">
|
|
||||||
<tbody>
|
|
||||||
<? foreach($years as $year => $months){
|
|
||||||
$yearHeader = Formatter::ToPlainText($year);
|
|
||||||
?>
|
|
||||||
<tr class="year-header">
|
|
||||||
<th colspan="13" scope="colgroup" id="<?= $yearHeader ?>"><?= Formatter::ToPlainText((string)$year) ?></th>
|
|
||||||
</tr>
|
|
||||||
<tr class="mid-header">
|
|
||||||
<th id="<?= $yearHeader?>-type" scope="col">Month</th>
|
|
||||||
<th id="<?= $yearHeader ?>-ebooks" scope="col">Ebooks</th>
|
|
||||||
<th id="<?= $yearHeader ?>-updated" scope="col">Updated</th>
|
|
||||||
<th id="<?= $yearHeader ?>-download" colspan="10" scope="col">Ebook format</th>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<? foreach($months as $month => $items){
|
|
||||||
$monthHeader = Formatter::ToPlainText($month);
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<th class="row-header" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-type" 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() ?>
|
||||||
|
|
69
www/bulk-downloads/months/index.php
Normal file
69
www/bulk-downloads/months/index.php
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<?
|
||||||
|
require_once('Core.php');
|
||||||
|
|
||||||
|
use function Safe\apcu_fetch;
|
||||||
|
|
||||||
|
$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.
|
||||||
|
$forbiddenException = new Exceptions\InvalidPatronException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$years = [];
|
||||||
|
|
||||||
|
try{
|
||||||
|
$years = apcu_fetch('bulk-downloads-years');
|
||||||
|
}
|
||||||
|
catch(Safe\Exceptions\ApcuException $ex){
|
||||||
|
$result = Library::RebuildBulkDownloadsCache();
|
||||||
|
$years = $result['years'];
|
||||||
|
}
|
||||||
|
|
||||||
|
?><?= Template::Header(['title' => 'Downloads by Month', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks released in a given month.']) ?>
|
||||||
|
<main>
|
||||||
|
<section class="bulk-downloads">
|
||||||
|
<h1>Downloads by Month</h1>
|
||||||
|
<? if($forbiddenException !== null){ ?>
|
||||||
|
<?= Template::Error(['exception' => $forbiddenException]) ?>
|
||||||
|
<? } ?>
|
||||||
|
<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>If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
|
||||||
|
<table class="download-list">
|
||||||
|
<tbody>
|
||||||
|
<? foreach($years as $year => $months){
|
||||||
|
$yearHeader = Formatter::ToPlainText($year);
|
||||||
|
?>
|
||||||
|
<tr class="year-header">
|
||||||
|
<th colspan="13" scope="colgroup" id="<?= $yearHeader ?>"><?= Formatter::ToPlainText((string)$year) ?></th>
|
||||||
|
</tr>
|
||||||
|
<tr class="mid-header">
|
||||||
|
<th id="<?= $yearHeader?>-type" scope="col">Month</th>
|
||||||
|
<th id="<?= $yearHeader ?>-ebooks" scope="col">Ebooks</th>
|
||||||
|
<th id="<?= $yearHeader ?>-updated" scope="col">Updated</th>
|
||||||
|
<th id="<?= $yearHeader ?>-download" colspan="10" scope="col">Ebook format</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<? foreach($months as $month => $collection){
|
||||||
|
$monthHeader = Formatter::ToPlainText($month);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-type" id="<?= $monthHeader ?>"><?= Formatter::ToPlainText($month) ?></th>
|
||||||
|
<td class="number" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-ebooks"><?= Formatter::ToPlainText(number_format($collection->EbookCount)) ?></td>
|
||||||
|
<td class="number" headers="<?= $yearHeader ?> <?= $monthHeader ?> <?= $yearHeader ?>-updated"><?= Formatter::ToPlainText($collection->UpdatedString) ?></td>
|
||||||
|
|
||||||
|
<? foreach($collection->ZipFiles 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>
|
||||||
|
</main>
|
||||||
|
<?= Template::Footer() ?>
|
37
www/bulk-downloads/subjects/index.php
Normal file
37
www/bulk-downloads/subjects/index.php
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?
|
||||||
|
require_once('Core.php');
|
||||||
|
|
||||||
|
use function Safe\apcu_fetch;
|
||||||
|
|
||||||
|
$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.
|
||||||
|
$forbiddenException = new Exceptions\InvalidPatronException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
try{
|
||||||
|
$subjects = apcu_fetch('bulk-downloads-subjects');
|
||||||
|
}
|
||||||
|
catch(Safe\Exceptions\ApcuException $ex){
|
||||||
|
$result = Library::RebuildBulkDownloadsCache();
|
||||||
|
$subjects = $result['subjects'];
|
||||||
|
}
|
||||||
|
|
||||||
|
?><?= Template::Header(['title' => 'Downloads by Subject', 'highlight' => '', 'description' => 'Download zip files containing all of the Standard Ebooks by a given subject.']) ?>
|
||||||
|
<main>
|
||||||
|
<section class="bulk-downloads">
|
||||||
|
<h1>Downloads by Subject</h1>
|
||||||
|
<? if($forbiddenException !== null){ ?>
|
||||||
|
<?= Template::Error(['exception' => $forbiddenException]) ?>
|
||||||
|
<? } ?>
|
||||||
|
<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>If you’re a Patrons Circle member, when prompted enter your email address and leave the password field blank to download these files.</p>
|
||||||
|
<?= Template::BulkDownloadTable(['label' => 'Subject', 'collections' => $subjects]); ?>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?= Template::Footer() ?>
|
|
@ -737,6 +737,7 @@ ul.message.error li:only-child{
|
||||||
|
|
||||||
.download-list tbody .row-header{
|
.download-list tbody .row-header{
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,7 @@ require_once('Core.php');
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p>Access to <a href="/bulk-downloads">bulk ebook downloads</a> to easily download entire months’ worth of ebooks at once.</p>
|
<p>Access to <a href="/bulk-downloads">bulk ebook downloads</a> to easily download whole collections of ebooks at once.</p>
|
||||||
</li>
|
</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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue