Add automatic donation drives

This commit is contained in:
Alex Cabal 2024-10-17 11:55:03 -05:00
parent d8ed44e20e
commit 19cf14c1aa
10 changed files with 396 additions and 296 deletions

View file

@ -15,10 +15,8 @@ parameters:
- %rootDir%/../../../scripts
dynamicConstantNames:
- SITE_STATUS
- DONATION_HOLIDAY_ALERT_ON
- DONATION_ALERT_ON
- DONATION_DRIVE_ON
- DONATION_DRIVE_COUNTER_ON
- DONATION_DRIVE_ENABLED
- DONATION_DRIVE_COUNTER_ENABLED
earlyTerminatingMethodCalls:
Template:
- Emit403

View file

@ -74,15 +74,26 @@ const ARTWORK_UPLOADS_LOG_FILE_PATH = '/var/log/local/artwork-uploads.log'; // M
define('PD_YEAR', intval((new DateTimeImmutable('now', new DateTimeZone('America/Juneau')))->format('Y')) - 96); // Latest continental US time zone.
define('PD_STRING', 'January 1, ' . (PD_YEAR + 1));
define('DONATION_HOLIDAY_ALERT_ON', NOW > new DateTimeImmutable('November 15, ' . NOW->format('Y')) || NOW < new DateTimeImmutable('January 7, ' . NOW->add(new DateInterval('P1Y'))->format('Y')));
define('DONATION_ALERT_ON', DONATION_HOLIDAY_ALERT_ON || rand(1, 4) == 2);
// Controls the progress bar donation dialog.
const DONATION_DRIVES_ENABLED = true; // **`TRUE`** to enable automatic donation drives; **`FALSE`** to disable all donation drives.
const DONATION_DRIVE_DATES = [
new DonationDrive(
'Spring drive',
new DateTimeImmutable('Second Monday of May'),
new DateTimeImmutable('Second Monday of May +2 weeks'),
50,
20
),
new DonationDrive(
'Holiday drive',
NOW < new DateTimeImmutable('November 15') ? new DateTimeImmutable('November 15') : new DateTimeImmutable('November 15 -1 year'),
NOW < new DateTimeImmutable('January 7') ? new DateTimeImmutable('January 7') : new DateTimeImmutable('January 7 +1 year'),
50,
20
)
];
// Controls the progress bar donation dialog
const DONATION_DRIVE_ON = true;
const DONATION_DRIVE_START = new DateTimeImmutable('May 20, 2024 00:00:00 America/New_York');
const DONATION_DRIVE_END = new DateTimeImmutable('June 3, 2024 23:59:00 America/New_York');
// Controls the countdown donation dialog
const DONATION_DRIVE_COUNTER_ON = false;
// Controls the countdown donation dialog, basically unused right now.
const DONATION_DRIVE_COUNTER_ENABLED = false;
const DONATION_DRIVE_COUNTER_START = new DateTimeImmutable('May 2, 2022 00:00:00 America/New_York');
const DONATION_DRIVE_COUNTER_END = new DateTimeImmutable('May 8, 2022 23:59:00 America/New_York');

95
lib/DonationDrive.php Normal file
View file

@ -0,0 +1,95 @@
<?
use Safe\DateTimeImmutable;
/**
* @property int $DonationCount
* @property int $StretchDonationCount
* @property bool $IsStretchEnabled
* @property int $TargetDonationCount
*/
class DonationDrive{
use Traits\Accessor;
protected int $_DonationCount;
protected int $_StretchDonationCount;
protected bool $_IsStretchEnabled;
protected int $_TargetDonationCount;
public function __construct(public string $Name, public DateTimeImmutable $Start, public DateTimeImmutable $End, public int $BaseTargetDonationCount, public int $StretchTargetDonationCount){
}
public static function GetByIsRunning(): ?DonationDrive{
foreach(DONATION_DRIVE_DATES as $donationDrive){
if(NOW > $donationDrive->Start && NOW < $donationDrive->End){
return $donationDrive;
}
}
return null;
}
protected function GetDonationCount(): int{
if(!isset($this->_DonationCount)){
$this->_DonationCount = Db::QueryInt('
SELECT sum(cnt)
from
(
(
# Anonymous patrons, i.e. from AOGF
select count(*) cnt from Payments
where
UserId is null
and
(
(IsRecurring = true and Amount >= 10 and Created >= ?)
or
(IsRecurring = false and Amount >= 100 and Created >= ?)
)
)
union all
(
# All non-anonymous patrons
select count(*) as cnt from Patrons
where Created >= ?
)
) x
', [$this->Start, $this->Start, $this->Start]);
}
return $this->_DonationCount;
}
protected function GetTargetDonationCount(): int{
if(!isset($this->_TargetDonationCount)){
$this->_TargetDonationCount = $this->BaseTargetDonationCount;
if($this->DonationCount > $this->BaseTargetDonationCount){
$this->_TargetDonationCount = $this->_TargetDonationCount + $this->StretchTargetDonationCount;
}
}
return $this->_TargetDonationCount;
}
protected function GetStretchDonationCount(): int{
if(!isset($this->_StretchDonationCount)){
$this->_StretchDonationCount = $this->DonationCount - $this->BaseTargetDonationCount;
if($this->_StretchDonationCount < 0){
$this->_StretchDonationCount = 0;
}
}
return $this->_StretchDonationCount;
}
protected function GetIsStretchEnabled(): bool{
if(!isset($this->_IsStretchEnabled)){
$this->_IsStretchEnabled = false;
if($this->StretchTargetDonationCount > 0 && $this->DonationCount >= $this->BaseTargetDonationCount){
$this->_IsStretchEnabled = true;
}
}
return $this->_IsStretchEnabled;
}
}

View file

@ -1,5 +1,20 @@
<? if($GLOBALS['User'] === null){
// The Kindle browsers renders <aside> as an undismissable popup. Serve a <div> to Kindle instead. See https://github.com/standardebooks/web/issues/204
<?
// Hide this alert if...
$donationDrive = DonationDrive::GetByIsRunning();
if(
$GLOBALS['User'] !== null // If a user is logged in.
||
$donationDrive !== null // There is a currently-running donation drive.
||
rand(1, 5) <= 3 // A 3-in-5 chance occurs.
){
return;
}
if($GLOBALS['User'] === null){
// The Kindle browsers renders `<aside>` as an undismissable popup. Serve a `<div>` to Kindle instead.
// See <https://github.com/standardebooks/web/issues/204>.
$element = 'aside';
if(stripos($_SERVER['HTTP_USER_AGENT'] ?? '', 'kindle') !== false){

View file

@ -1,6 +1,6 @@
<?
// Hide the alert if the user has closed it.
if(!DONATION_DRIVE_COUNTER_ON || ($autoHide ?? $_COOKIE['hide-donation-alert'] ?? false) || NOW > DONATION_DRIVE_COUNTER_END){
if(!DONATION_DRIVE_COUNTER_ENABLED || ($autoHide ?? $_COOKIE['hide-donation-alert'] ?? false) || NOW > DONATION_DRIVE_COUNTER_END){
return;
}

View file

@ -1,54 +1,23 @@
<?
$totalCurrent = 0;
$baseTarget = 50;
$stretchCurrent = 0;
$stretchTarget = 20;
$donationDrive = DonationDrive::GetByIsRunning();
// Hide the alert if...
if(
!DONATION_DRIVE_ON // The drive isn't running
!DONATION_DRIVES_ENABLED // Drives aren't enabled.
||
($autoHide ?? $_COOKIE['hide-donation-alert'] ?? false) // If the user has hidden the box
($autoHide ?? $_COOKIE['hide-donation-alert'] ?? false) // If the user has hidden the box.
||
$GLOBALS['User'] !== null // If a user is logged in
$GLOBALS['User'] !== null // If a user is logged in.
||
DONATION_DRIVE_START > NOW // If the drive hasn't started yet
||
NOW > DONATION_DRIVE_END // If the drive has ended
$donationDrive === null // There is no donation drive running right now.
){
return;
}
$autoHide = $autoHide ?? true;
$showDonateButton = $showDonateButton ?? true;
$totalCurrent = Db::QueryInt('
SELECT sum(cnt)
from
(
(
# Anonymous patrons, i.e. from AOGF
select count(*) cnt from Payments
where
UserId is null
and
(
(IsRecurring = true and Amount >= 10 and Created >= ?)
or
(IsRecurring = false and Amount >= 100 and Created >= ?)
)
)
union all
(
# All non-anonymous patrons
select count(*) as cnt from Patrons
where Created >= ?
)
) x
', [DONATION_DRIVE_START, DONATION_DRIVE_START, DONATION_DRIVE_START]);
$totalTarget = $baseTarget;
$deadline = DONATION_DRIVE_END->format('F j');
$timeLeft = NOW->diff(DONATION_DRIVE_END);
$deadline = $donationDrive->End->format('F j');
$timeLeft = NOW->diff($donationDrive->End);
$timeString = '';
if($timeLeft->days < 1 && $timeLeft->h < 20){
$timeString = 'Just hours';
@ -71,13 +40,6 @@ else{
$timeString = 'Only ' . $timeString;
}
}
$stretchOn = false;
if($stretchTarget > 0 && $totalCurrent >= $baseTarget){
$stretchOn = true;
$stretchCurrent = $totalCurrent - $baseTarget;
$totalTarget = $baseTarget + $stretchTarget;
}
?>
<aside class="donation closable">
<? if($autoHide){ ?>
@ -86,38 +48,38 @@ if($stretchTarget > 0 && $totalCurrent >= $baseTarget){
<button class="close" title="Close this box">Close this box</button>
</form>
<? } ?>
<? if(!$stretchOn){ ?>
<? if(!$donationDrive->IsStretchEnabled){ ?>
<header>
<? if($timeLeft->days > 5){ ?>
<p>Help us reach <?= number_format($baseTarget) ?> new patrons by <?= $deadline ?></p>
<p>Help us reach <?= number_format($donationDrive->TargetDonationCount) ?> new patrons by <?= $deadline ?></p>
<? }else{ ?>
<p><?= $timeString ?> left to help us reach <?= number_format($baseTarget) ?> new patrons!</p>
<p><?= $timeString ?> left to help us reach <?= number_format($donationDrive->TargetDonationCount) ?> new patrons!</p>
<? } ?>
</header>
<? }else{ ?>
<header>
<p>Help us meet our stretch goal of<br/> <?= number_format($totalTarget) ?> new patrons by <?= $deadline ?></p>
<p>Help us meet our stretch goal of<br/> <?= number_format($donationDrive->TargetDonationCount) ?> new patrons by <?= $deadline ?></p>
</header>
<? } ?>
<div class="progress">
<div aria-hidden="true">
<p class="start">0</p>
<p><?= number_format($totalCurrent) ?>/<?= number_format($totalTarget) ?></p>
<? if($stretchOn){ ?>
<p class="stretch-base"><?= number_format($baseTarget) ?></p>
<p><?= number_format($donationDrive->DonationCount) ?>/<?= number_format($donationDrive->TargetDonationCount) ?></p>
<? if($donationDrive->IsStretchEnabled){ ?>
<p class="stretch-base"><?= number_format($donationDrive->BaseTargetDonationCount) ?></p>
<? } ?>
<p class="target"><?= number_format($totalTarget) ?></p>
<p class="target"><?= number_format($donationDrive->TargetDonationCount) ?></p>
</div>
<progress max="<?= $baseTarget ?>" value="<?= $totalCurrent - $stretchCurrent ?>"></progress>
<? if($stretchOn){ ?>
<progress class="stretch" max="<?= $stretchTarget ?>" value="<?= $stretchCurrent ?>"></progress>
<progress max="<?= $donationDrive->TargetDonationCount ?>" value="<?= $donationDrive->DonationCount - $donationDrive->StretchDonationCount ?>"></progress>
<? if($donationDrive->IsStretchEnabled){ ?>
<progress class="stretch" max="<?= $donationDrive->StretchTargetDonationCount ?>" value="<?= $donationDrive->StretchDonationCount ?>"></progress>
<? } ?>
</div>
<? if($stretchOn){ ?>
<p>When we started this drive, we set a goal of <?= number_format($baseTarget) ?> Patrons Circle members by <?= $deadline ?>. Thanks to the incredible generosity of literature lovers like you, we hit that goal!</p>
<p>Since theres still some time left in our drive, we thought wed challenge our readers to help us reach our stretch goal of <?= number_format($totalTarget) ?> patrons, so that we can continue on a rock-solid financial footing. Will you help us with a donation, and support free and unrestricted digital literature?</p>
<? if($donationDrive->IsStretchEnabled){ ?>
<p>When we started this drive, we set a goal of <?= number_format($donationDrive->BaseTargetDonationCount) ?> Patrons Circle members by <?= $deadline ?>. Thanks to the incredible generosity of literature lovers like you, we hit that goal!</p>
<p>Since theres still some time left in our drive, we thought wed challenge our readers to help us reach our stretch goal of <?= number_format($donationDrive->TargetDonationCount) ?> patrons, so that we can continue on a rock-solid financial footing. Will you help us with a donation, and support free and unrestricted digital literature?</p>
<? }else{ ?>
<p>It takes a huge amount of resources and highly-skilled work to create and distribute each of our free ebooks, and we need your support to keep it up. Thats why we want to welcome <?= number_format($baseTarget) ?> new patrons by <?= $deadline ?>. Its our patrons who keep us on the stable financial footing we need to continue producing and giving away beautiful ebooks.</p>
<p>It takes a huge amount of resources and highly-skilled work to create and distribute each of our free ebooks, and we need your support to keep it up. Thats why we want to welcome <?= number_format($donationDrive->TargetDonationCount) ?> new patrons by <?= $deadline ?>. Its our patrons who keep us on the stable financial footing we need to continue producing and giving away beautiful ebooks.</p>
<p>Will you become a patron, and support free and unrestricted digital literature?</p>
<? } ?>
<? if($showDonateButton){ ?>

View file

@ -39,9 +39,9 @@ catch(Exceptions\CollectionNotFoundException){
<h1 class="is-collection"><?= $pageHeader ?></h1>
<?= Template::DonationCounter() ?>
<?= Template::DonationProgress() ?>
<? if(!DONATION_DRIVE_ON && !DONATION_DRIVE_COUNTER_ON && DONATION_HOLIDAY_ALERT_ON){ ?>
<?= Template::DonationAlert() ?>
<? } ?>
<?= Template::DonationAlert() ?>
<p class="ebooks-toolbar">
<a class="button" href="/collections/<?= Formatter::EscapeHtml($collection) ?>/downloads">Download collection</a>
<a class="button" href="/collections/<?= Formatter::EscapeHtml($collection) ?>/feeds">Collection feeds</a>

View file

@ -2314,7 +2314,7 @@ h1 + ul.message,
.masthead ol li p{
margin-top: 0;
text-align: left;
display: inline-block; /* Prevent a p from breaking across column division */
display: inline-block; /* Prevent a `<p>` from breaking across column division */
}
.masthead hgroup * + *{
@ -2326,7 +2326,6 @@ h1 + ul.message,
}
.donate aside{
border-top: 1px dashed var(--sub-text);
margin-top: 1rem;
padding-top: 1rem;
font-style: italic;

View file

@ -116,7 +116,7 @@ catch(Exceptions\EbookNotFoundException){
<meta property="schema:description" content="<?= Formatter::EscapeHtml($ebook->Description) ?>"/>
<meta property="schema:url" content="<?= SITE_URL . Formatter::EscapeHtml($ebook->Url) ?>"/>
<? if($ebook->WikipediaUrl){ ?>
<meta property="schema:sameAs" content="<?= Formatter::EscapeHtml($ebook->WikipediaUrl) ?>"/>
<meta property="schema:sameAs" content="<?= Formatter::EscapeHtml($ebook->WikipediaUrl) ?>"/>
<? } ?>
<header>
<hgroup>
@ -126,18 +126,24 @@ catch(Exceptions\EbookNotFoundException){
For example, William Wordsworth & Samuel Coleridge will both link to /ebooks/william-wordsworth_samuel-taylor-coleridge
But, each author is an individual, so we have to differentiate them in RDFa with `resource` */ ?>
<? if($author->Name != 'Anonymous'){ ?>
<h2><a property="schema:author" typeof="schema:Person" href="<?= Formatter::EscapeHtml($ebook->AuthorsUrl) ?>" resource="<?= '/ebooks/' . $author->UrlName ?>">
<span property="schema:name"><?= Formatter::EscapeHtml($author->Name) ?></span>
<meta property="schema:url" content="<?= SITE_URL . Formatter::EscapeHtml($ebook->AuthorsUrl) ?>"/>
<? if($author->NacoafUrl){ ?><meta property="schema:sameAs" content="<?= Formatter::EscapeHtml($author->NacoafUrl) ?>"/><? } ?>
<? if($author->WikipediaUrl){ ?><meta property="schema:sameAs" content="<?= Formatter::EscapeHtml($author->WikipediaUrl) ?>"/><? } ?>
</a>
</h2>
<h2><a property="schema:author" typeof="schema:Person" href="<?= Formatter::EscapeHtml($ebook->AuthorsUrl) ?>" resource="<?= '/ebooks/' . $author->UrlName ?>">
<span property="schema:name"><?= Formatter::EscapeHtml($author->Name) ?></span>
<meta property="schema:url" content="<?= SITE_URL . Formatter::EscapeHtml($ebook->AuthorsUrl) ?>"/>
<? if($author->NacoafUrl){ ?>
<meta property="schema:sameAs" content="<?= Formatter::EscapeHtml($author->NacoafUrl) ?>"/>
<? } ?>
<? if($author->WikipediaUrl){ ?>
<meta property="schema:sameAs" content="<?= Formatter::EscapeHtml($author->WikipediaUrl) ?>"/>
<? } ?>
</a>
</h2>
<? } ?>
<? } ?>
</hgroup>
<picture>
<? if($ebook->HeroImage2xAvifUrl !== null){ ?><source srcset="<?= $ebook->HeroImage2xAvifUrl ?> 2x, <?= $ebook->HeroImageAvifUrl ?> 1x" type="image/avif"/><? } ?>
<? if($ebook->HeroImage2xAvifUrl !== null){ ?>
<source srcset="<?= $ebook->HeroImage2xAvifUrl ?> 2x, <?= $ebook->HeroImageAvifUrl ?> 1x" type="image/avif"/>
<? } ?>
<source srcset="<?= $ebook->HeroImage2xUrl ?> 2x, <?= $ebook->HeroImageUrl ?> 1x" type="image/jpg"/>
<img src="<?= $ebook->HeroImage2xUrl ?>" alt="" height="439" width="1318" />
</picture>
@ -147,7 +153,7 @@ catch(Exceptions\EbookNotFoundException){
<aside id="reading-ease">
<p><?= number_format($ebook->WordCount) ?> words (<?= $ebook->ReadingTime ?>) with a reading ease of <?= $ebook->ReadingEase ?> (<?= $ebook->ReadingEaseDescription ?>)</p>
<? if($ebook->ContributorsHtml !== null){ ?>
<p><?= $ebook->ContributorsHtml ?></p>
<p><?= $ebook->ContributorsHtml ?></p>
<? } ?>
<? if(sizeof($ebook->Collections) > 0){ ?>
<? foreach($ebook->Collections as $collection){ ?>
@ -157,7 +163,7 @@ catch(Exceptions\EbookNotFoundException){
<?= $collection->Type ?>.
<? } ?>
<? }else{ ?>
collection.
collection.
<? } ?>
</p>
<? } ?>
@ -169,9 +175,9 @@ catch(Exceptions\EbookNotFoundException){
<h2>Description</h2>
<?= Template::DonationCounter() ?>
<?= Template::DonationProgress() ?>
<? if(!DONATION_DRIVE_ON && !DONATION_DRIVE_COUNTER_ON && DONATION_ALERT_ON){ ?>
<?= Template::DonationAlert() ?>
<? } ?>
<?= Template::DonationAlert() ?>
<? if($ebook->LongDescription === null){ ?>
<p><i>Theres no description for this ebook yet.</i></p>
<? }else{ ?>
@ -180,239 +186,251 @@ catch(Exceptions\EbookNotFoundException){
</section>
<? if($ebook->HasDownloads){ ?>
<section id="read-free" property="schema:workExample" typeof="schema:Book" resource="<?= Formatter::EscapeHtml($ebook->Url) ?>/downloads">
<meta property="schema:bookFormat" content="http://schema.org/EBook"/>
<meta property="schema:url" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->Url) ?>"/>
<meta property="schema:license" content="https://creativecommons.org/publicdomain/zero/1.0/"/>
<div property="schema:publisher" typeof="schema:Organization">
<meta property="schema:name" content="Standard Ebooks"/>
<meta property="schema:logo" content="https://standardebooks.org/images/logo-full.svg"/>
<meta property="schema:url" content="https://standardebooks.org"/>
</div>
<meta property="schema:image" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->DistCoverUrl) ?>"/>
<meta property="schema:thumbnailUrl" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->Url . '/downloads/cover-thumbnail.jpg') ?>"/>
<meta property="schema:inLanguage" content="<?= Formatter::EscapeHtml($ebook->Language) ?>"/>
<meta property="schema:datePublished" content="<?= Formatter::EscapeHtml($ebook->Created->format('Y-m-d')) ?>"/>
<meta property="schema:dateModified" content="<?= Formatter::EscapeHtml($ebook->Updated->format('Y-m-d')) ?>"/>
<div property="schema:potentialAction" typeof="http://schema.org/ReadAction">
<meta property="schema:actionStatus" content="http://schema.org/PotentialActionStatus"/>
<div property="schema:target" typeof="schema:EntryPoint">
<meta property="schema:urlTemplate" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->Url) ?>"/>
<meta property="schema:actionPlatform" content="http://schema.org/DesktopWebPlatform"/>
<meta property="schema:actionPlatform" content="http://schema.org/AndroidPlatform"/>
<meta property="schema:actionPlatform" content="http://schema.org/IOSPlatform"/>
<section id="read-free" property="schema:workExample" typeof="schema:Book" resource="<?= Formatter::EscapeHtml($ebook->Url) ?>/downloads">
<meta property="schema:bookFormat" content="http://schema.org/EBook"/>
<meta property="schema:url" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->Url) ?>"/>
<meta property="schema:license" content="https://creativecommons.org/publicdomain/zero/1.0/"/>
<div property="schema:publisher" typeof="schema:Organization">
<meta property="schema:name" content="Standard Ebooks"/>
<meta property="schema:logo" content="https://standardebooks.org/images/logo-full.svg"/>
<meta property="schema:url" content="https://standardebooks.org"/>
</div>
<div property="schema:expectsAcceptanceOf" typeof="schema:Offer">
<meta property="schema:category" content="nologinrequired"/>
<div property="schema:eligibleRegion" typeof="schema:Country">
<meta property="schema:name" content="US"/>
<meta property="schema:image" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->DistCoverUrl) ?>"/>
<meta property="schema:thumbnailUrl" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->Url . '/downloads/cover-thumbnail.jpg') ?>"/>
<meta property="schema:inLanguage" content="<?= Formatter::EscapeHtml($ebook->Language) ?>"/>
<meta property="schema:datePublished" content="<?= Formatter::EscapeHtml($ebook->Created->format('Y-m-d')) ?>"/>
<meta property="schema:dateModified" content="<?= Formatter::EscapeHtml($ebook->Updated->format('Y-m-d')) ?>"/>
<div property="schema:potentialAction" typeof="http://schema.org/ReadAction">
<meta property="schema:actionStatus" content="http://schema.org/PotentialActionStatus"/>
<div property="schema:target" typeof="schema:EntryPoint">
<meta property="schema:urlTemplate" content="<?= Formatter::EscapeHtml(SITE_URL . $ebook->Url) ?>"/>
<meta property="schema:actionPlatform" content="http://schema.org/DesktopWebPlatform"/>
<meta property="schema:actionPlatform" content="http://schema.org/AndroidPlatform"/>
<meta property="schema:actionPlatform" content="http://schema.org/IOSPlatform"/>
</div>
<div property="schema:expectsAcceptanceOf" typeof="schema:Offer">
<meta property="schema:category" content="nologinrequired"/>
<div property="schema:eligibleRegion" typeof="schema:Country">
<meta property="schema:name" content="US"/>
</div>
</div>
</div>
</div>
<?= $ebook->GenerateContributorsRdfa() ?>
<h2>Read free</h2>
<p class="us-pd-warning">This ebook is thought to be free of copyright restrictions in the United States. It may still be under copyright in other countries. If youre not located in the United States, you must check your local laws to verify that this ebook is free of copyright restrictions in the country youre located in before accessing, downloading, or using it.</p>
<?= $ebook->GenerateContributorsRdfa() ?>
<h2>Read free</h2>
<p class="us-pd-warning">This ebook is thought to be free of copyright restrictions in the United States. It may still be under copyright in other countries. If youre not located in the United States, you must check your local laws to verify that this ebook is free of copyright restrictions in the country youre located in before accessing, downloading, or using it.</p>
<div class="downloads-container">
<figure class="<? if($ebook->WordCount < 100000){ ?>small<? }elseif($ebook->WordCount < 200000){ ?>medium<? }elseif($ebook->WordCount <= 300000){ ?>large<? }elseif($ebook->WordCount < 400000){ ?>xlarge<? }else{ ?>xxlarge<? } ?>">
<picture>
<source srcset="<?= $ebook->CoverImage2xAvifUrl ?> 2x, <?= $ebook->CoverImageAvifUrl ?> 1x" type="image/avif"/>
<source srcset="<?= $ebook->CoverImage2xUrl ?> 2x, <?= $ebook->CoverImageUrl ?> 1x" type="image/jpg"/>
<img src="<?= $ebook->CoverImageUrl ?>" alt="" height="363" width="242"/>
</picture>
</figure>
<div>
<section id="download">
<h3>Download for ereaders</h3>
<ul>
<? /* Leave the @download attribute empty to have the browser use the target filename in the save-as dialog */ ?>
<? if($ebook->EpubUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:description" content="epub"/>
<meta property="schema:encodingFormat" content="application/epub+zip"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::Epub->value ?>" class="epub">Compatible epub</a></span> <span></span> <span>All devices and apps except Kindles and Kobos.</span>
</p>
</li>
<? } ?>
<div class="downloads-container">
<figure class="<? if($ebook->WordCount < 100000){ ?>small<? }elseif($ebook->WordCount < 200000){ ?>medium<? }elseif($ebook->WordCount <= 300000){ ?>large<? }elseif($ebook->WordCount < 400000){ ?>xlarge<? }else{ ?>xxlarge<? } ?>">
<picture>
<source srcset="<?= $ebook->CoverImage2xAvifUrl ?> 2x, <?= $ebook->CoverImageAvifUrl ?> 1x" type="image/avif"/>
<source srcset="<?= $ebook->CoverImage2xUrl ?> 2x, <?= $ebook->CoverImageUrl ?> 1x" type="image/jpg"/>
<img src="<?= $ebook->CoverImageUrl ?>" alt="" height="363" width="242"/>
</picture>
</figure>
<div>
<section id="download">
<h3>Download for ereaders</h3>
<ul>
<? /* Leave the @download attribute empty to have the browser use the target filename in the save-as dialog */ ?>
<? if($ebook->EpubUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:description" content="epub"/>
<meta property="schema:encodingFormat" content="application/epub+zip"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::Epub->value ?>" class="epub">Compatible epub</a></span> <span></span> <span>All devices and apps except Kindles and Kobos.</span>
</p>
</li>
<? } ?>
<? if($ebook->Azw3Url !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:encodingFormat" content="application/x-mobipocket-ebook"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::Azw3->value ?>" class="amazon"><span property="schema:description">azw3</span></a></span> <span></span> <span>Kindle devices and apps.<? if($ebook->KindleCoverUrl !== null){ ?> Also download the <a href="<?= $ebook->KindleCoverUrl ?>">Kindle cover thumbnail</a> to see the cover in your Kindles library. Despite what youve been told, <a href="/help/how-to-use-our-ebooks#kindle-epub">Kindle does not natively support epub.</a> You may also be interested in our <a href="/help/how-to-use-our-ebooks#kindle-faq">Kindle FAQ</a>.<? }else{ ?> Also see our <a href="/how-to-use-our-ebooks#kindle-faq">Kindle FAQ</a>.<? } ?></span>
</p>
</li>
<? } ?>
<? if($ebook->Azw3Url !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:encodingFormat" content="application/x-mobipocket-ebook"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::Azw3->value ?>" class="amazon"><span property="schema:description">azw3</span></a></span> <span></span> <span>Kindle devices and apps.<? if($ebook->KindleCoverUrl !== null){ ?> Also download the <a href="<?= $ebook->KindleCoverUrl ?>">Kindle cover thumbnail</a> to see the cover in your Kindles library. Despite what youve been told, <a href="/help/how-to-use-our-ebooks#kindle-epub">Kindle does not natively support epub.</a> You may also be interested in our <a href="/help/how-to-use-our-ebooks#kindle-faq">Kindle FAQ</a>.<? }else{ ?> Also see our <a href="/how-to-use-our-ebooks#kindle-faq">Kindle FAQ</a>.<? } ?></span>
</p>
</li>
<? } ?>
<? if($ebook->KepubUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:encodingFormat" content="application/kepub+zip"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::Kepub->value ?>" class="kobo"><span property="schema:description">kepub</span></a></span> <span></span> <span>Kobo devices and apps. You may also be interested in our <a href="/help/how-to-use-our-ebooks#kobo-faq">Kobo FAQ</a>.</span>
</p>
</li>
<? } ?>
<? if($ebook->KepubUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:encodingFormat" content="application/kepub+zip"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::Kepub->value ?>" class="kobo"><span property="schema:description">kepub</span></a></span> <span></span> <span>Kobo devices and apps. You may also be interested in our <a href="/help/how-to-use-our-ebooks#kobo-faq">Kobo FAQ</a>.</span>
</p>
</li>
<? } ?>
<? if($ebook->AdvancedEpubUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:encodingFormat" content="application/epub+zip"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::AdvancedEpub->value ?>" class="epub"><span property="schema:description">Advanced epub</span></a></span> <span></span> <span>An advanced format that uses the latest technology not yet fully supported by most ereaders.</span>
</p>
</li>
<? } ?>
</ul>
<aside>
<p>Read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which file to download</a> and <a href="/help/how-to-use-our-ebooks#transferring-to-your-ereader">how to transfer them to your ereader</a>.</p>
</aside>
</section>
<? if($ebook->TextUrl !== null || $ebook->TextSinglePageUrl !== null){ ?>
<section id="read-online">
<h3>Read online</h3>
<ul>
<? if($ebook->TextUrl !== null){ ?>
<li>
<p>
<a href="<?= $ebook->TextUrl ?>" class="list">Start from the table of contents</a>
</p>
</li>
<? } ?>
<? if($ebook->TextSinglePageUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:mediaObject">
<meta property="schema:description" content="XHTML"/>
<meta property="schema:encodingFormat" content="application/xhtml+xml"/>
<p<? if($ebook->TextSinglePageSizeNumber >= 3 && $ebook->TextSinglePageSizeUnit == 'M'){ ?> class="has-size"<? } ?>>
<a property="schema:contentUrl" href="<?= $ebook->TextSinglePageUrl ?>" class="page">Read on one page</a><? if($ebook->TextSinglePageSizeNumber >= 3 && $ebook->TextSinglePageSizeUnit == 'M'){ ?><span><?= $ebook->TextSinglePageSizeNumber ?>MB</span><? } ?>
</p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
<? if($ebook->AdvancedEpubUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:MediaObject">
<meta property="schema:encodingFormat" content="application/epub+zip"/>
<p>
<span><a property="schema:contentUrl" rel="nofollow" href="<?= $ebook->Url ?>/download?format=<?= EbookFormatType::AdvancedEpub->value ?>" class="epub"><span property="schema:description">Advanced epub</span></a></span> <span></span> <span>An advanced format that uses the latest technology not yet fully supported by most ereaders.</span>
</p>
</li>
<? } ?>
</ul>
<aside>
<p>Read about <a href="/help/how-to-use-our-ebooks#which-file-to-download">which file to download</a> and <a href="/help/how-to-use-our-ebooks#transferring-to-your-ereader">how to transfer them to your ereader</a>.</p>
</aside>
</section>
<? if($ebook->TextUrl !== null || $ebook->TextSinglePageUrl !== null){ ?>
<section id="read-online">
<h3>Read online</h3>
<ul>
<? if($ebook->TextUrl !== null){ ?>
<li>
<p>
<a href="<?= $ebook->TextUrl ?>" class="list">Start from the table of contents</a>
</p>
</li>
<? } ?>
<? if($ebook->TextSinglePageUrl !== null){ ?>
<li property="schema:encoding" typeof="schema:mediaObject">
<meta property="schema:description" content="XHTML"/>
<meta property="schema:encodingFormat" content="application/xhtml+xml"/>
<p<? if($ebook->TextSinglePageSizeNumber >= 3 && $ebook->TextSinglePageSizeUnit == 'M'){ ?> class="has-size"<? } ?>>
<a property="schema:contentUrl" href="<?= $ebook->TextSinglePageUrl ?>" class="page">Read on one page</a><? if($ebook->TextSinglePageSizeNumber >= 3 && $ebook->TextSinglePageSizeUnit == 'M'){ ?><span><?= $ebook->TextSinglePageSizeNumber ?>MB</span><? } ?>
</p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
</div>
</div>
</div>
</section>
</section>
<? } ?>
<section id="history">
<h2>A brief history of this ebook</h2>
<ol>
<? foreach($ebook->GitCommits as $commit){ ?>
<li>
<time datetime="<?= $commit->Created->format(DateTimeImmutable::RFC3339) ?>"><?= $commit->Created->format('M j, Y') ?></time>
<p><a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>/commit/<?= Formatter::EscapeHtml($commit->Hash) ?>"><?= Formatter::EscapeHtml($commit->Message) ?></a></p>
</li>
<li>
<time datetime="<?= $commit->Created->format(DateTimeImmutable::RFC3339) ?>"><?= $commit->Created->format('M j, Y') ?></time>
<p><a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>/commit/<?= Formatter::EscapeHtml($commit->Hash) ?>"><?= Formatter::EscapeHtml($commit->Message) ?></a></p>
</li>
<? } ?>
</ol>
<? if($ebook->GitHubUrl !== null){ ?>
<aside>
<p>Read the <a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>/commits/master">full change history</a>.</p>
</aside>
<aside>
<p>Read the <a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>/commits/master">full change history</a>.</p>
</aside>
<? } ?>
</section>
<? if($ebook->GitHubUrl !== null || $ebook->WikipediaUrl !== null){ ?>
<section id="details">
<h2>More details</h2>
<ul>
<? if($ebook->GitHubUrl !== null){ ?>
<li>
<p><a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>" class="github">This ebooks source code at GitHub</a></p>
</li>
<? } ?>
<? if($ebook->WikipediaUrl !== null){ ?>
<li>
<p><a href="<?= Formatter::EscapeHtml($ebook->WikipediaUrl) ?>" class="wikipedia">This book at Wikipedia</a></p>
</li>
<? } ?>
</ul>
</section>
<section id="details">
<h2>More details</h2>
<ul>
<? if($ebook->GitHubUrl !== null){ ?>
<li>
<p><a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>" class="github">This ebooks source code at GitHub</a></p>
</li>
<? } ?>
<? if($ebook->WikipediaUrl !== null){ ?>
<li>
<p><a href="<?= Formatter::EscapeHtml($ebook->WikipediaUrl) ?>" class="wikipedia">This book at Wikipedia</a></p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
<? if(sizeof($transcriptionSources) > 0 || sizeof($scanSources) > 0 || sizeof($otherSources) > 0){ ?>
<section id="sources">
<h2>Sources</h2>
<? if(sizeof($transcriptionSources) > 0){ ?>
<section id="transcriptions">
<h3>Transcriptions</h3>
<ul>
<? foreach($transcriptionSources as $source){ ?>
<li>
<p>
<? if($source->Type == EbookSourceType::ProjectGutenberg){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="project-gutenberg">Transcription at Project Gutenberg</a>
<? }elseif($source->Type == EbookSourceType::ProjectGutenbergAustralia){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="project-gutenberg">Transcription at Project Gutenberg Australia</a>
<? }elseif($source->Type == EbookSourceType::ProjectGutenbergCanada){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="project-gutenberg">Transcription at Project Gutenberg Canada</a>
<? }elseif($source->Type == EbookSourceType::Wikisource){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="wikisource">Transcription at Wikisource</a>
<? }elseif($source->Type == EbookSourceType::FadedPage){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe">Transcription at Faded Page</a>
<? }else{?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe">Transcription</a>
<section id="sources">
<h2>Sources</h2>
<? if(sizeof($transcriptionSources) > 0){ ?>
<section id="transcriptions">
<h3>Transcriptions</h3>
<ul>
<? foreach($transcriptionSources as $source){ ?>
<li>
<p>
<? if($source->Type == EbookSourceType::ProjectGutenberg){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="project-gutenberg">Transcription at Project Gutenberg</a>
<? }elseif($source->Type == EbookSourceType::ProjectGutenbergAustralia){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="project-gutenberg">Transcription at Project Gutenberg Australia</a>
<? }elseif($source->Type == EbookSourceType::ProjectGutenbergCanada){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="project-gutenberg">Transcription at Project Gutenberg Canada</a>
<? }elseif($source->Type == EbookSourceType::Wikisource){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="wikisource">Transcription at Wikisource</a>
<? }elseif($source->Type == EbookSourceType::FadedPage){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe">Transcription at Faded Page</a>
<? }else{?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe">Transcription</a>
<? } ?>
</p>
</li>
<? } ?>
</p>
</li>
<? } ?>
</ul>
</ul>
</section>
<? } ?>
<? if(sizeof($scanSources) > 0){ ?>
<section id="page-scans">
<h3>Page scans</h3>
<ul>
<? foreach($scanSources as $source){ ?>
<li>
<p>
<? if($source->Type == EbookSourceType::InternetArchive){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="internet-archive">Page scans at the Internet Archive</a>
<? }elseif($source->Type == EbookSourceType::HathiTrust){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="hathitrust">Page scans at HathiTrust</a>
<? }elseif($source->Type == EbookSourceType::GoogleBooks){ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="google">Page scans at Google Books</a>
<? }else{ ?>
<a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe">Page scans</a>
<? } ?>
</p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
<? if(sizeof($otherSources) > 0){ ?>
<section id="other-sources">
<h3>Other sources</h3>
<ul>
<? foreach($otherSources as $source){ ?>
<li>
<p>
<? if($source->Type == EbookSourceType::Other){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe"><?= Formatter::EscapeHtml(preg_replace(['|https?://(en\.)?|', '|/.+$|'], '', (string)$source->Url)) /* force type to (string) to satisfy PHPStan */ ?></a><? } ?>
</p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
</section>
<? } ?>
<? if(sizeof($scanSources) > 0){ ?>
<section id="page-scans">
<h3>Page scans</h3>
<ul>
<? foreach($scanSources as $source){ ?>
<li>
<p>
<? if($source->Type == EbookSourceType::InternetArchive){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="internet-archive">Page scans at the Internet Archive</a>
<? }elseif($source->Type == EbookSourceType::HathiTrust){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="hathitrust">Page scans at HathiTrust</a>
<? }elseif($source->Type == EbookSourceType::GoogleBooks){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="google">Page scans at Google Books</a>
<? }else{ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe">Page scans</a><? } ?>
</p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
<? if(sizeof($otherSources) > 0){ ?>
<section id="other-sources">
<h3>Other sources</h3>
<ul>
<? foreach($otherSources as $source){ ?>
<li>
<p>
<? if($source->Type == EbookSourceType::Other){ ?><a href="<?= Formatter::EscapeHtml($source->Url) ?>" class="globe"><?= Formatter::EscapeHtml(preg_replace(['|https?://(en\.)?|', '|/.+$|'], '', (string)$source->Url)) /* force type to (string) to satisfy PHPStan */ ?></a><? } ?>
</p>
</li>
<? } ?>
</ul>
</section>
<? } ?>
</section>
<? } ?>
<section id="improve-this-ebook">
<h2>Improve this ebook</h2>
<p>Anyone can contribute to make a Standard Ebook better for everyone!</p>
<p>To report typos, typography errors, or other corrections, see <a href="/contribute/report-errors">how to report errors</a>.</p>
<? if($ebook->GitHubUrl !== null){ ?><p>If youre comfortable with technology and want to contribute directly, check out <a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>">this ebooks GitHub repository</a> and our <a href="/contribute">contributors section</a>.</p><? } ?>
<? if($ebook->GitHubUrl !== null){ ?>
<p>If youre comfortable with technology and want to contribute directly, check out <a href="<?= Formatter::EscapeHtml($ebook->GitHubUrl) ?>">this ebooks GitHub repository</a> and our <a href="/contribute">contributors section</a>.</p>
<? } ?>
<p>You can also <a href="/donate">donate to Standard Ebooks</a> to help fund continuing improvement of this and other ebooks.</p>
</section>
<? if(sizeof($carousel) > 0){ ?>
<aside id="more-ebooks">
<h2>More free<? if($carouselTag !== null){ ?> <?= strtolower($carouselTag->Name) ?><? } ?> ebooks</h2>
<ul>
<? foreach($carousel as $carouselEbook){ ?>
<li>
<a href="<?= $carouselEbook->Url ?>">
<picture>
<? if($carouselEbook->CoverImage2xAvifUrl !== null){ ?><source srcset="<?= $carouselEbook->CoverImage2xAvifUrl ?> 2x, <?= $carouselEbook->CoverImageAvifUrl ?> 1x" type="image/avif"/><? } ?>
<source srcset="<?= $carouselEbook->CoverImage2xUrl ?> 2x, <?= $carouselEbook->CoverImageUrl ?> 1x" type="image/jpg"/>
<img src="<?= $carouselEbook->CoverImageUrl ?>" alt="<?= Formatter::EscapeHtml(strip_tags($carouselEbook->TitleWithCreditsHtml)) ?>" height="200" width="134" loading="lazy"/>
</picture>
</a>
</li>
<? } ?>
</ul>
</aside>
<aside id="more-ebooks">
<h2>More free<? if($carouselTag !== null){ ?> <?= strtolower($carouselTag->Name) ?><? } ?> ebooks</h2>
<ul>
<? foreach($carousel as $carouselEbook){ ?>
<li>
<a href="<?= $carouselEbook->Url ?>">
<picture>
<? if($carouselEbook->CoverImage2xAvifUrl !== null){ ?><source srcset="<?= $carouselEbook->CoverImage2xAvifUrl ?> 2x, <?= $carouselEbook->CoverImageAvifUrl ?> 1x" type="image/avif"/><? } ?>
<source srcset="<?= $carouselEbook->CoverImage2xUrl ?> 2x, <?= $carouselEbook->CoverImageUrl ?> 1x" type="image/jpg"/>
<img src="<?= $carouselEbook->CoverImageUrl ?>" alt="<?= Formatter::EscapeHtml(strip_tags($carouselEbook->TitleWithCreditsHtml)) ?>" height="200" width="134" loading="lazy"/>
</picture>
</a>
</li>
<? } ?>
</ul>
</aside>
<? } ?>
</article>
</main>

View file

@ -109,9 +109,9 @@ catch(Exceptions\AppException $ex){
<h1><?= $pageHeader ?></h1>
<?= Template::DonationCounter() ?>
<?= Template::DonationProgress() ?>
<? if(!DONATION_DRIVE_ON && !DONATION_DRIVE_COUNTER_ON && DONATION_HOLIDAY_ALERT_ON){ ?>
<?= Template::DonationAlert() ?>
<? } ?>
<?= Template::DonationAlert() ?>
<?= Template::SearchForm(['query' => $query, 'tags' => $tags, 'sort' => $sort, 'view' => $view, 'perPage' => $perPage]) ?>
<? if(sizeof($ebooks) == 0){ ?>
<p class="no-results">No ebooks matched your filters. You can try different filters, or <a href="/ebooks">browse all of our ebooks</a>.</p>
@ -123,7 +123,9 @@ catch(Exceptions\AppException $ex){
<a<? if($page > 1){ ?> href="/ebooks?page=<?= $page - 1 ?><? if($queryStringWithoutPage != ''){ ?>&amp;<?= Formatter::EscapeHtml($queryStringWithoutPage) ?><? } ?>" rel="prev"<? }else{ ?> aria-disabled="true"<? } ?>>Back</a>
<ol>
<? for($i = 1; $i < $pages + 1; $i++){ ?>
<li<? if($page == $i){ ?> class="highlighted"<? } ?>><a href="/ebooks?page=<?= $i ?><? if($queryStringWithoutPage != ''){ ?>&amp;<?= Formatter::EscapeHtml($queryStringWithoutPage) ?><? } ?>"><?= $i ?></a></li>
<li<? if($page == $i){ ?> class="highlighted"<? } ?>>
<a href="/ebooks?page=<?= $i ?><? if($queryStringWithoutPage != ''){ ?>&amp;<?= Formatter::EscapeHtml($queryStringWithoutPage) ?><? } ?>"><?= $i ?></a>
</li>
<? } ?>
</ol>
<a<? if($page < ceil($totalEbooks / $perPage)){ ?> href="/ebooks?page=<?= $page + 1 ?><? if($queryStringWithoutPage != ''){ ?>&amp;<?= Formatter::EscapeHtml($queryStringWithoutPage) ?><? } ?>" rel="next"<? }else{ ?> aria-disabled="true"<? } ?>>Next</a>