Add placeholders for ebooks

This commit is contained in:
Mike Colagrosso 2024-12-13 11:45:14 -06:00 committed by Alex Cabal
parent cf5f488cae
commit 1ab95df084
52 changed files with 1192 additions and 237 deletions

View file

@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS `Benefits` (
`CanReviewArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanReviewOwnArtwork` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanEditUsers` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanCreateEbookPlaceholders` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`UserId`),
KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `EbookPlaceholders` (
`EbookId` int(10) unsigned NOT NULL,
`YearPublished` smallint unsigned NULL,
`Status` enum('wanted', 'in_progress') NULL,
`Difficulty` enum('beginner', 'intermediate', 'advanced') NULL,
`TranscriptionUrl` varchar(511) NULL,
`IsWanted` boolean NOT NULL DEFAULT FALSE,
`IsPatron` boolean NOT NULL DEFAULT FALSE,
`Notes` TEXT NULL DEFAULT NULL,
PRIMARY KEY (`EbookId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS `Ebooks` (
`Identifier` varchar(511) NOT NULL,
`Created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`Updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`WwwFilesystemPath` varchar(511) NOT NULL,
`RepoFilesystemPath` varchar(511) NOT NULL,
`WwwFilesystemPath` varchar(511) NULL,
`RepoFilesystemPath` varchar(511) NULL,
`KindleCoverUrl` varchar(511) NULL,
`EpubUrl` varchar(511) NULL,
`AdvancedEpubUrl` varchar(511) NULL,
@ -14,16 +14,16 @@ CREATE TABLE IF NOT EXISTS `Ebooks` (
`Title` varchar(255) NOT NULL,
`FullTitle` varchar(255) NULL,
`AlternateTitle` varchar(255) NULL,
`Description` text NOT NULL,
`LongDescription` text NOT NULL,
`Language` varchar(10) NOT NULL,
`WordCount` int(10) unsigned NOT NULL,
`ReadingEase` float NOT NULL,
`Description` text NULL,
`LongDescription` text NULL,
`Language` varchar(10) NULL,
`WordCount` int(10) unsigned NULL,
`ReadingEase` float NULL,
`GitHubUrl` varchar(255) NULL,
`WikipediaUrl` varchar(255) NULL,
`EbookCreated` datetime NOT NULL,
`EbookUpdated` datetime NOT NULL,
`TextSinglePageByteCount` bigint unsigned NOT NULL,
`EbookCreated` datetime NULL,
`EbookUpdated` datetime NULL,
`TextSinglePageByteCount` bigint unsigned NULL,
`IndexableText` text NOT NULL,
PRIMARY KEY (`EbookId`),
UNIQUE KEY `index1` (`Identifier`),

View file

@ -15,6 +15,7 @@ class Benefits{
public bool $CanReviewArtwork = false;
public bool $CanReviewOwnArtwork = false;
public bool $CanEditUsers = false;
public bool $CanCreateEbookPlaceholders = false;
protected bool $_HasBenefits;
@ -27,6 +28,8 @@ class Benefits{
$this->CanReviewOwnArtwork
||
$this->CanEditUsers
||
$this->CanCreateEbookPlaceholders
){
return true;
}
@ -58,18 +61,18 @@ class Benefits{
public function Create(): void{
Db::Query('
INSERT into Benefits (UserId, CanAccessFeeds, CanVote, CanBulkDownload, CanUploadArtwork, CanReviewArtwork, CanReviewOwnArtwork, CanEditUsers)
values (?, ?, ?, ?, ?, ?, ?, ?)
', [$this->UserId, $this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers]);
INSERT into Benefits (UserId, CanAccessFeeds, CanVote, CanBulkDownload, CanUploadArtwork, CanReviewArtwork, CanReviewOwnArtwork, CanEditUsers, CanCreateEbookPlaceholders)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
', [$this->UserId, $this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->CanCreateEbookPlaceholders]);
}
public function Save(): void{
Db::Query('
UPDATE Benefits
set CanAccessFeeds = ?, CanVote = ?, CanBulkDownload = ?, CanUploadArtwork = ?, CanReviewArtwork = ?, CanReviewOwnArtwork = ?, CanEditUsers = ?
set CanAccessFeeds = ?, CanVote = ?, CanBulkDownload = ?, CanUploadArtwork = ?, CanReviewArtwork = ?, CanReviewOwnArtwork = ?, CanEditUsers = ?, CanCreateEbookPlaceholders = ?
where
UserId = ?
', [$this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->UserId]);
', [$this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->CanCreateEbookPlaceholders, $this->UserId]);
}
public function FillFromHttpPost(): void{
@ -80,5 +83,6 @@ class Benefits{
$this->PropertyFromHttp('CanReviewArtwork');
$this->PropertyFromHttp('CanReviewOwnArtwork');
$this->PropertyFromHttp('CanEditUsers');
$this->PropertyFromHttp('CanCreateEbookPlaceholders');
}
}

View file

@ -29,7 +29,8 @@ const MANUAL_PATH = WEB_ROOT . '/manual';
const EBOOKS_DIST_PATH = WEB_ROOT . '/ebooks/';
const COVER_ART_UPLOAD_PATH = '/images/cover-uploads/';
const EBOOKS_IDENTIFIER_PREFIX = 'url:https://standardebooks.org/ebooks/';
const EBOOKS_IDENTIFIER_ROOT = 'url:https://standardebooks.org';
const EBOOKS_IDENTIFIER_PREFIX = EBOOKS_IDENTIFIER_ROOT . '/ebooks/';
const DATABASE_DEFAULT_DATABASE = 'se';
const DATABASE_DEFAULT_HOST = 'localhost';

View file

@ -41,6 +41,28 @@ class Contributor{
// METHODS
// *******
/**
* @return array<Contributor>
*/
public static function GetAllAuthorNames(): array{
return Db::Query('
SELECT DISTINCT Name
from Contributors
where MarcRole = "aut"
order by Name asc', [], Contributor::class);
}
/**
* @return array<Contributor>
*/
public static function GetAllTranslatorNames(): array{
return Db::Query('
SELECT DISTINCT Name
from Contributors
where MarcRole = "trl"
order by Name asc', [], Contributor::class);
}
/**
* @throws Exceptions\ValidationException
*/

View file

@ -41,14 +41,15 @@ use function Safe\shell_exec;
* @property string $TextSinglePageUrl
* @property string $TextSinglePageSizeFormatted
* @property string $IndexableText
* @property EbookPlaceholder $EbookPlaceholder
*/
class Ebook{
use Traits\Accessor;
public int $EbookId;
public string $Identifier;
public string $WwwFilesystemPath;
public string $RepoFilesystemPath;
public ?string $WwwFilesystemPath = null;
public ?string $RepoFilesystemPath = null;
public ?string $KindleCoverUrl = null;
public ?string $EpubUrl = null;
public ?string $AdvancedEpubUrl = null;
@ -58,17 +59,17 @@ class Ebook{
public string $Title;
public ?string $FullTitle = null;
public ?string $AlternateTitle = null;
public string $Description;
public string $LongDescription;
public string $Language;
public int $WordCount;
public float $ReadingEase;
public ?string $Description = null;
public ?string $LongDescription = null;
public ?string $Language = null;
public ?int $WordCount = null;
public ?float $ReadingEase = null;
public ?string $GitHubUrl = null;
public ?string $WikipediaUrl = null;
/** When the ebook was published. */
public DateTimeImmutable $EbookCreated;
public ?DateTimeImmutable $EbookCreated = null;
/** When the ebook was updated. */
public DateTimeImmutable $EbookUpdated;
public ?DateTimeImmutable $EbookUpdated = null;
/** When the database row was created. */
public DateTimeImmutable $Created;
/** When the database row was updated. */
@ -116,6 +117,7 @@ class Ebook{
protected string $_TextSinglePageUrl;
protected string $_TextSinglePageSizeFormatted;
protected string $_IndexableText;
protected ?EbookPlaceholder $_EbookPlaceholder = null;
// *******
// GETTERS
@ -318,7 +320,7 @@ class Ebook{
protected function GetUrl(): string{
if(!isset($this->_Url)){
$this->_Url = str_replace(WEB_ROOT, '', $this->WwwFilesystemPath);
$this->_Url = str_replace(EBOOKS_IDENTIFIER_ROOT, '', $this->Identifier);
}
return $this->_Url;
@ -554,7 +556,7 @@ class Ebook{
protected function GetTextSinglePageSizeFormatted(): string{
if(!isset($this->_TextSinglePageSizeFormatted)){
$bytes = $this->TextSinglePageByteCount;
$sizes = array('B', 'KB', 'MB', 'GB', 'TB', 'PB');
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$index = 0;
while($bytes >= 1024 && $index < count($sizes) - 1){
@ -608,6 +610,18 @@ class Ebook{
return $this->_IndexableText;
}
protected function GetEbookPlaceholder(): ?EbookPlaceholder{
if(!isset($this->_EbookPlaceholder)){
$this->_EbookPlaceholder = Db::Query('
SELECT *
from EbookPlaceholders
where EbookId = ?
', [$this->EbookId], EbookPlaceholder::class)[0] ?? null;
}
return $this->_EbookPlaceholder;
}
// ***********
// ORM METHODS
@ -970,6 +984,59 @@ class Ebook{
return $ebook;
}
/**
* Joins the `Name` properites of `Contributor` objects as a URL slug, e.g.,
*
* ```
* ([0] => Contributor Object ([Name] => William Wordsworth), ([1] => Contributor Object ([Name] => Samuel Coleridge)))
* ```
*
* returns `william-wordsworth_samuel-taylor-coleridge`.
*
* @param array<Contributor> $contributors
*/
protected static function GetContributorsUrlSlug(array $contributors): string{
return implode('_', array_map('Formatter::MakeUrlSafe', array_column($contributors, 'Name')));
}
/**
* Populates the `Identifier` property based on the `Title`, `Authors`, `Translators`, and `Illustrators`. Used when creating ebook placeholders.
*
* @throws Exceptions\InvalidEbookIdentifierException
*/
public function FillIdentifierFromTitleAndContributors(): void{
if(!isset($this->Authors) || sizeof($this->Authors) == 0){
throw new Exceptions\InvalidEbookIdentifierException('Authors required');
}
if(!isset($this->Title)){
throw new Exceptions\InvalidEbookIdentifierException('Title required');
}
$authorString = Ebook::GetContributorsUrlSlug($this->Authors);
$titleString = Formatter::MakeUrlSafe($this->Title);
$translatorString = '';
$illustratorString = '';
if(isset($this->Translators) && sizeof($this->Translators) > 0){
$translatorString = Ebook::GetContributorsUrlSlug($this->Translators);
}
if(isset($this->Illustrators) && sizeof($this->Illustrators) > 0){
$illustratorString = Ebook::GetContributorsUrlSlug($this->Illustrators);
}
$this->Identifier = EBOOKS_IDENTIFIER_PREFIX . $authorString . '/' . $titleString;
if($translatorString != ''){
$this->Identifier .= '/' . $translatorString;
}
if($illustratorString != ''){
$this->Identifier .= '/' . $illustratorString;
}
}
// *******
// METHODS
@ -996,13 +1063,12 @@ class Ebook{
$error->Add(new Exceptions\EbookIdentifierRequiredException());
}
$this->WwwFilesystemPath = trim($this->WwwFilesystemPath ?? '');
if($this->WwwFilesystemPath == ''){
$this->WwwFilesystemPath = null;
}
if(isset($this->WwwFilesystemPath)){
$this->WwwFilesystemPath = trim($this->WwwFilesystemPath);
if($this->WwwFilesystemPath == ''){
$error->Add(new Exceptions\EbookWwwFilesystemPathRequiredException());
}
if(strlen($this->WwwFilesystemPath) > EBOOKS_MAX_LONG_STRING_LENGTH){
$error->Add(new Exceptions\StringTooLongException('Ebook WwwFilesystemPath'));
}
@ -1011,17 +1077,13 @@ class Ebook{
$error->Add(new Exceptions\InvalidEbookWwwFilesystemPathException($this->WwwFilesystemPath));
}
}
else{
$error->Add(new Exceptions\EbookWwwFilesystemPathRequiredException());
$this->RepoFilesystemPath = trim($this->RepoFilesystemPath ?? '');
if($this->RepoFilesystemPath == ''){
$this->RepoFilesystemPath = null;
}
if(isset($this->RepoFilesystemPath)){
$this->RepoFilesystemPath = trim($this->RepoFilesystemPath);
if($this->RepoFilesystemPath == ''){
$error->Add(new Exceptions\EbookRepoFilesystemPathRequiredException());
}
if(strlen($this->RepoFilesystemPath) > EBOOKS_MAX_LONG_STRING_LENGTH){
$error->Add(new Exceptions\StringTooLongException('Ebook RepoFilesystemPath'));
}
@ -1030,9 +1092,6 @@ class Ebook{
$error->Add(new Exceptions\InvalidEbookRepoFilesystemPathException($this->RepoFilesystemPath));
}
}
else{
$error->Add(new Exceptions\EbookRepoFilesystemPathRequiredException());
}
$this->KindleCoverUrl = trim($this->KindleCoverUrl ?? '');
if($this->KindleCoverUrl == ''){
@ -1157,51 +1216,36 @@ class Ebook{
$error->Add(new Exceptions\StringTooLongException('Ebook AlternateTitle'));
}
if(isset($this->Description)){
$this->Description = trim($this->Description);
if($this->Description == ''){
$error->Add(new Exceptions\EbookDescriptionRequiredException());
}
}
else{
$error->Add(new Exceptions\EbookDescriptionRequiredException());
$this->Description = trim($this->Description ?? '');
if($this->Description == ''){
$this->Description = null;
}
if(isset($this->LongDescription)){
$this->LongDescription = trim($this->LongDescription);
if($this->LongDescription == ''){
$error->Add(new Exceptions\EbookLongDescriptionRequiredException());
}
if(isset($this->Description) && strlen($this->Description) > EBOOKS_MAX_STRING_LENGTH){
$error->Add(new Exceptions\StringTooLongException('Ebook Description'));
}
else{
$error->Add(new Exceptions\EbookLongDescriptionRequiredException());
$this->LongDescription = trim($this->LongDescription ?? '');
if($this->LongDescription == ''){
$this->LongDescription = null;
}
$this->Language = trim($this->Language ?? '');
if($this->Language == ''){
$this->Language = null;
}
if(isset($this->Language)){
$this->Language = trim($this->Language);
if($this->Language == ''){
$error->Add(new Exceptions\EbookLanguageRequiredException());
}
if(strlen($this->Language) > 10){
$error->Add(new Exceptions\StringTooLongException('Ebook Language: ' . $this->Language));
}
}
else{
$error->Add(new Exceptions\EbookLanguageRequiredException());
}
if(isset($this->WordCount)){
if($this->WordCount <= 0){
$error->Add(new Exceptions\InvalidEbookWordCountException('Invalid Ebook WordCount: ' . $this->WordCount));
}
}
else{
$error->Add(new Exceptions\EbookWordCountRequiredException());
}
if(isset($this->ReadingEase)){
// In theory, Flesch reading ease can be negative, but in practice it's positive.
@ -1209,9 +1253,6 @@ class Ebook{
$error->Add(new Exceptions\InvalidEbookReadingEaseException('Invalid Ebook ReadingEase: ' . $this->ReadingEase));
}
}
else{
$error->Add(new Exceptions\EbookReadingEaseRequiredException());
}
$this->GitHubUrl = trim($this->GitHubUrl ?? '');
if($this->GitHubUrl == ''){
@ -1248,9 +1289,6 @@ class Ebook{
$error->Add(new Exceptions\InvalidEbookCreatedDatetimeException($this->EbookCreated));
}
}
else{
$error->Add(new Exceptions\EbookCreatedDatetimeRequiredException());
}
if(isset($this->EbookUpdated)){
if($this->EbookUpdated > NOW){
@ -1258,18 +1296,12 @@ class Ebook{
}
}
else{
$error->Add(new Exceptions\EbookUpdatedDatetimeRequiredException());
}
if(isset($this->TextSinglePageByteCount)){
if($this->TextSinglePageByteCount <= 0){
$error->Add(new Exceptions\InvalidEbookTextSinglePageByteCountException('Invalid Ebook TextSinglePageByteCount: ' . $this->TextSinglePageByteCount));
}
}
else{
$error->Add(new Exceptions\EbookTextSinglePageByteCountRequiredException());
}
if(isset($this->IndexableText)){
$this->IndexableText = trim($this->IndexableText ?? '');
@ -1282,6 +1314,23 @@ class Ebook{
$error->Add(new Exceptions\EbookIndexableTextRequiredException());
}
if(isset($this->EbookPlaceholder)){
try{
$this->EbookPlaceholder->Validate();
}
catch(Exceptions\ValidationException $ex){
$error->Add($ex);
}
}
if($this->IsPlaceholder() && !isset($this->EbookPlaceholder)){
$error->Add(new Exceptions\EbookMissingPlaceholderException());
}
if(!$this->IsPlaceholder() && isset($this->EbookPlaceholder)){
$error->Add(new Exceptions\EbookUnexpectedPlaceholderException());
}
if($error->HasExceptions){
throw $error;
}
@ -1289,6 +1338,7 @@ class Ebook{
/**
* @throws Exceptions\ValidationException
* @throws Exceptions\DuplicateEbookException
*/
public function CreateOrUpdate(): void{
try{
@ -1550,6 +1600,10 @@ class Ebook{
return $string;
}
public function IsPlaceholder(): bool{
return $this->WwwFilesystemPath === null;
}
/**
* If the given list of elements has an element that is not `''`, return that value; otherwise, return `null`.
*
@ -1572,10 +1626,19 @@ class Ebook{
/**
* @throws Exceptions\ValidationException
* @throws Exceptions\DuplicateEbookException If an `Ebook` with the given identifier already exists.
*/
public function Create(): void{
$this->Validate();
try{
Ebook::GetByIdentifier($this->Identifier);
throw new Exceptions\DuplicateEbookException($this->Identifier);
}
catch(Exceptions\EbookNotFoundException){
// Pass.
}
$this->CreateTags();
$this->CreateLocSubjects();
$this->CreateCollections();
@ -1623,6 +1686,7 @@ class Ebook{
$this->AddSources();
$this->AddContributors();
$this->AddTocEntries();
$this->AddEbookPlaceholder();
}
/**
@ -1690,6 +1754,9 @@ class Ebook{
$this->RemoveTocEntries();
$this->AddTocEntries();
$this->RemoveEbookPlaceholder();
$this->AddEbookPlaceholder();
}
private function RemoveTags(): void{
@ -1848,6 +1915,24 @@ class Ebook{
}
}
private function RemoveEbookPlaceholder(): void{
Db::Query('
DELETE from EbookPlaceholders
where EbookId = ?
', [$this->EbookId]
);
}
/**
* @throws Exceptions\ValidationException
*/
private function AddEbookPlaceholder(): void{
if(isset($this->EbookPlaceholder)){
$this->EbookPlaceholder->EbookId = $this->EbookId;
$this->EbookPlaceholder->Create();
}
}
// ***********
// ORM METHODS
// ***********
@ -1929,6 +2014,9 @@ class Ebook{
}
/**
* Queries for books in a collection.
*
* Puts ebooks without a `SequenceNumber` at the end of the list, which is more common in a collection with both published and placeholder ebooks.
* @return array<Ebook>
*/
public static function GetAllByCollection(string $collection): array{
@ -1938,13 +2026,16 @@ class Ebook{
inner join CollectionEbooks ce using (EbookId)
inner join Collections c using (CollectionId)
where c.UrlName = ?
order by ce.SequenceNumber, e.EbookCreated desc
order by ce.SequenceNumber is null, ce.SequenceNumber, e.EbookCreated desc
', [$collection], Ebook::class);
return $ebooks;
}
/**
* Queries for related to books to be shown, e.g., in a carousel.
*
* Filters out placeholder books because they are not useful for browsing.
* @return array<Ebook>
*/
public static function GetAllByRelated(Ebook $ebook, int $count, ?EbookTag $relatedTag): array{
@ -1955,6 +2046,7 @@ class Ebook{
inner join EbookTags et using (EbookId)
where et.TagId = ?
and et.EbookId != ?
and e.WwwFilesystemPath is not null
order by rand()
limit ?
', [$relatedTag->TagId, $ebook->EbookId, $count], Ebook::class);
@ -1964,6 +2056,7 @@ class Ebook{
SELECT *
from Ebooks
where EbookId != ?
and WwwFilesystemPath is not null
order by rand()
limit ?
', [$ebook->EbookId, $count], Ebook::class);
@ -1977,25 +2070,43 @@ class Ebook{
*
* @return array{ebooks: array<Ebook>, ebooksCount: int}
*/
public static function GetAllByFilter(string $query = null, array $tags = [], Enums\EbookSortType $sort = null, int $page = 1, int $perPage = EBOOKS_PER_PAGE): array{
public static function GetAllByFilter(string $query = null, array $tags = [], Enums\EbookSortType $sort = null, int $page = 1, int $perPage = EBOOKS_PER_PAGE, Enums\EbookReleaseStatusFilter $releaseStatusFilter = Enums\EbookReleaseStatusFilter::All): array{
$limit = $perPage;
$offset = (($page - 1) * $perPage);
$joinContributors = '';
$joinTags = '';
$params = [];
$whereCondition = 'where true';
switch($releaseStatusFilter){
case Enums\EbookReleaseStatusFilter::Released:
$whereCondition = 'where e.WwwFilesystemPath is not null';
break;
case Enums\EbookReleaseStatusFilter::Placeholder:
$whereCondition = 'where e.WwwFilesystemPath is null';
break;
case Enums\EbookReleaseStatusFilter::All:
default:
if($query !== null && $query != ''){
// If the query is present, show both released and placeholder ebooks.
$whereCondition = 'where true';
}else{
// If there is no query, hide placeholder ebooks.
$whereCondition = 'where e.WwwFilesystemPath is not null';
}
break;
}
$orderBy = 'e.EbookCreated desc';
if($sort == Enums\EbookSortType::AuthorAlpha){
$joinContributors = 'inner join Contributors con using (EbookId)';
$whereCondition .= ' and con.MarcRole = "aut"';
$orderBy = 'con.SortName, e.EbookCreated desc';
$orderBy = 'e.WwwFilesystemPath is null, con.SortName, e.EbookCreated desc'; // Put placeholders at the end
}
elseif($sort == Enums\EbookSortType::ReadingEase){
$orderBy = 'e.ReadingEase desc';
}
elseif($sort == Enums\EbookSortType::Length){
$orderBy = 'e.WordCount';
$orderBy = 'e.WwwFilesystemPath is null, e.WordCount'; // Put placeholders at the end
}
if(sizeof($tags) > 0 && !in_array('all', $tags)){ // 0 tags means "all ebooks"

88
lib/EbookPlaceholder.php Normal file
View file

@ -0,0 +1,88 @@
<?
/**
* @property bool $IsPublicDomain
*/
class EbookPlaceholder{
use Traits\Accessor;
use Traits\PropertyFromHttp;
public int $EbookId;
public ?int $YearPublished = null;
public bool $IsWanted = false;
public ?Enums\EbookPlaceholderStatus $Status = null;
public ?Enums\EbookPlaceholderDifficulty $Difficulty = null;
public ?string $TranscriptionUrl = null;
public bool $IsPatron = false;
public ?string $Notes = null;
protected bool $_IsPublicDomain;
protected function GetIsPublicDomain(): bool{
if(!isset($this->_IsPublicDomain)){
$this->_IsPublicDomain = $this->YearPublished === null ? true : $this->YearPublished <= PD_YEAR;
}
return $this->_IsPublicDomain;
}
public function FillFromHttpPost(): void{
$this->PropertyFromHttp('YearPublished');
$this->PropertyFromHttp('IsWanted');
// These properties apply only to books on the SE wanted list.
if($this->IsWanted){
$this->PropertyFromHttp('Status');
$this->PropertyFromHttp('Difficulty');
$this->PropertyFromHttp('TranscriptionUrl');
$this->PropertyFromHttp('IsPatron');
$this->PropertyFromHttp('Notes');
}
}
/**
* @throws Exceptions\ValidationException
*/
public function Validate(): void{
$thisYear = intval(NOW->format('Y'));
$error = new Exceptions\ValidationException();
if(isset($this->YearPublished) && ($this->YearPublished <= 0 || $this->YearPublished > $thisYear)){
$error->Add(new Exceptions\InvalidEbookPlaceholderYearPublishedException());
}
$this->TranscriptionUrl = trim($this->TranscriptionUrl ?? '');
if($this->TranscriptionUrl == ''){
$this->TranscriptionUrl = null;
}
$this->Notes = trim($this->Notes ?? '');
if($this->Notes == ''){
$this->Notes = null;
}
if($error->HasExceptions){
throw $error;
}
}
/**
* @throws Exceptions\ValidationException
*/
public function Create(): void{
$this->Validate();
Db::Query('
INSERT into EbookPlaceholders (EbookId, YearPublished, Status, Difficulty, TranscriptionUrl,
IsWanted, IsPatron, Notes)
values (?,
?,
?,
?,
?,
?,
?,
?)
', [$this->EbookId, $this->YearPublished, $this->Status, $this->Difficulty, $this->TranscriptionUrl,
$this->IsWanted, $this->IsPatron, $this->Notes]);
}
}

View file

@ -0,0 +1,8 @@
<?
namespace Enums;
enum EbookPlaceholderDifficulty: string{
case Beginner = 'beginner';
case Intermediate = 'intermediate';
case Advanced = 'advanced';
}

View file

@ -0,0 +1,7 @@
<?
namespace Enums;
enum EbookPlaceholderStatus: string{
case Wanted = 'wanted';
case InProgress = 'in_progress';
}

View file

@ -0,0 +1,8 @@
<?
namespace Enums;
enum EbookReleaseStatusFilter{
case All;
case Placeholder;
case Released;
}

View file

@ -0,0 +1,10 @@
<?
namespace Exceptions;
class DuplicateEbookException extends AppException{
public function __construct(string $identifier){
$this->message = 'Ebook already exists with identifier: ' . $identifier;
parent::__construct($this->message);
}
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookCreatedDatetimeRequiredException extends AppException{
/** @var string $message */
protected $message = 'EbookCreated datetime required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookDescriptionRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook Description required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookLanguageRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook language required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookLongDescriptionRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook LongDescription required.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class EbookMissingPlaceholderException extends AppException{
/** @var string $message */
protected $message = 'Ebook is a placeholder, but has no placeholder object.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookReadingEaseRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook ReadingEase required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookRepoFilesystemPathRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook RepoFilesystemPath required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookTextSinglePageByteCountRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook TextSinglePageByteCount required.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class EbookUnexpectedPlaceholderException extends AppException{
/** @var string $message */
protected $message = 'Ebook is not a placeholder, but has a placeholder object.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookUpdatedDatetimeRequiredException extends AppException{
/** @var string $message */
protected $message = 'EbookUpdated datetime required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookWordCountRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook WordCount required.';
}

View file

@ -1,7 +0,0 @@
<?
namespace Exceptions;
class EbookWwwFilesystemPathRequiredException extends AppException{
/** @var string $message */
protected $message = 'Ebook WwwFilesystemPath required.';
}

View file

@ -0,0 +1,5 @@
<?
namespace Exceptions;
class InvalidEbookIdentifierException extends AppException{
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidEbookPlaceholderYearPublishedException extends AppException{
/** @var string $message */
protected $message = 'Invalid ebook placeholder year published.';
}

View file

@ -106,6 +106,10 @@ function CreateZip(string $filePath, array $ebooks, string $type, string $webRoo
// Iterate over all ebooks and arrange them by publication month.
foreach(Ebook::GetAll() as $ebook){
if($ebook->IsPlaceholder()){
continue;
}
$timestamp = $ebook->EbookCreated->format('Y-m');
$updatedTimestamp = $ebook->EbookUpdated->getTimestamp();

View file

@ -100,6 +100,10 @@ foreach($dirs as $dir){
// Iterate over all ebooks to build the various feeds.
foreach(Ebook::GetAll() as $ebook){
if($ebook->IsPlaceholder()){
continue;
}
$allEbooks[] = $ebook;
$newestEbooks[] = $ebook;

View file

@ -12,7 +12,7 @@ $isEditForm = $isEditForm ?? false;
?>
<fieldset>
<legend>Artist details</legend>
<label class="user">
<label class="icon user">
<span>Name</span>
<span>For existing artists, leave the year of death blank.</span>
<datalist id="artist-names">
@ -31,7 +31,7 @@ $isEditForm = $isEditForm ?? false;
value="<?= Formatter::EscapeHtml($artwork->Artist->Name) ?>"
/>
</label>
<label class="year">
<label class="icon year">
<span>Year of death</span>
<span>If circa or unknown, enter the latest possible year.</span>
<? /* Not using `<input type="number">` for now, see <https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/>. */ ?>
@ -46,13 +46,13 @@ $isEditForm = $isEditForm ?? false;
</fieldset>
<fieldset>
<legend>Artwork details</legend>
<label class="picture">
<label class="icon picture">
Name
<input type="text" name="artwork-name" required="required"
value="<?= Formatter::EscapeHtml($artwork->Name) ?>"/>
</label>
<fieldset>
<label class="year">
<label class="icon year">
Year of completion
<input
type="text"
@ -70,7 +70,7 @@ $isEditForm = $isEditForm ?? false;
/> Year is circa
</label>
</fieldset>
<label class="tags">
<label class="icon tags">
<span>Tags</span>
<span>A list of comma-separated tags.</span>
<input
@ -116,7 +116,7 @@ $isEditForm = $isEditForm ?? false;
<span>This book was published in the U.S.</span>
<span>Yes, if a U.S. city appears anywhere near the publication year or rights statement.</span>
</label>
<label class="year">
<label class="icon year">
Year of publication
<input
type="text"

View file

@ -14,7 +14,7 @@
<? if($artwork->Ebook !== null && $artwork->Ebook->Url !== null){ ?>
<i>
<a href="<?= $artwork->Ebook->Url ?>"><?= Formatter::EscapeHtml($artwork->Ebook->Title) ?></a>
</i>
</i><? if($artwork->Ebook->IsPlaceholder()){ ?>(unreleased)<? } ?>
<? }else{ ?>
<code><?= Formatter::EscapeHtml($artwork->EbookUrl) ?></code> (unreleased)
<? } ?>

View file

@ -12,17 +12,21 @@ $collection = $collection ?? null;
<meta property="schema:name" content="<?= Formatter::EscapeHtml($collection->Name) ?>"/>
<? } ?>
<? foreach($ebooks as $ebook){ ?>
<li typeof="schema:Book"<? if($collection !== null){ ?> resource="<?= $ebook->Url ?>" property="schema:hasPart"<? if($ebook->GetCollectionPosition($collection) !== null){ ?> value="<?= $ebook->GetCollectionPosition($collection) ?>"<? } ?><? }else{ ?> about="<?= $ebook->Url ?>"<? } ?>>
<li typeof="schema:Book"<? if($collection !== null){ ?> resource="<?= $ebook->Url ?>" property="schema:hasPart"<? if($ebook->GetCollectionPosition($collection) !== null){ ?> value="<?= $ebook->GetCollectionPosition($collection) ?>"<? } ?><? }else{ ?> about="<?= $ebook->Url ?>"<? } ?><? if($ebook->EbookPlaceholder?->IsWanted){ ?> class="wanted"<? } ?>>
<? if($collection !== null && $ebook->GetCollectionPosition($collection) !== null){ ?>
<meta property="schema:position" content="<?= $ebook->GetCollectionPosition($collection) ?>"/>
<? } ?>
<div class="thumbnail-container" aria-hidden="true"><? /* We need a container in case the thumb is shorter than the description, so that the focus outline doesn't take up the whole grid space */ ?>
<a href="<?= $ebook->Url ?>" tabindex="-1" property="schema:url"<? if($collection !== null && $ebook->GetCollectionPosition($collection) !== null){ ?> data-ordinal="<?= $ebook->GetCollectionPosition($collection) ?>"<? } ?>>
<picture>
<? if($ebook->CoverImage2xAvifUrl !== null){ ?><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->CoverImage2xUrl ?>" alt="" property="schema:image" height="335" width="224"/>
</picture>
<? if($ebook->IsPlaceholder()){ ?>
<div class="placeholder-cover"></div><? /* Don't self-close as this changes how Chrome renders */ ?>
<? }else{ ?>
<picture>
<? if($ebook->CoverImage2xAvifUrl !== null){ ?><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->CoverImage2xUrl ?>" alt="" property="schema:image" height="335" width="224"/>
</picture>
<? } ?>
</a>
</div>
<p><a href="<?= $ebook->Url ?>" property="schema:url"><span property="schema:name"><?= Formatter::EscapeHtml($ebook->Title) ?></span></a></p>
@ -42,8 +46,16 @@ $collection = $collection ?? null;
<p><?= rtrim($ebook->ContributorsHtml, '.') ?></p>
</div>
<? } ?>
<p><?= number_format($ebook->WordCount) ?> words • <?= $ebook->ReadingEase ?> reading ease</p>
<ul class="tags"><? foreach($ebook->Tags as $tag){ ?><li><a href="<?= $tag->Url ?>"><?= Formatter::EscapeHtml($tag->Name) ?></a></li><? } ?></ul>
<? if(!$ebook->IsPlaceholder()){ ?>
<p><?= number_format($ebook->WordCount) ?> words • <?= $ebook->ReadingEase ?> reading ease</p>
<ul class="tags">
<? foreach($ebook->Tags as $tag){ ?>
<li>
<a href="<?= $tag->Url ?>"><?= Formatter::EscapeHtml($tag->Name) ?></a>
</li>
<? } ?>
</ul>
<? } ?>
</div>
<? } ?>
</li>

View file

@ -0,0 +1,223 @@
<?
/**
* @var ?Ebook $ebook
*/
$ebook = $ebook ?? new Ebook();
?>
<fieldset>
<legend>Contributors</legend>
<label class="icon user">
<span>Author</span>
<datalist id="author-names">
<? foreach(Contributor::GetAllAuthorNames() as $author){ ?>
<option value="<?= Formatter::EscapeHtml($author->Name) ?>"><?= Formatter::EscapeHtml($author->Name) ?></option>
<? } ?>
</datalist>
<input
type="text"
name="author-name-1"
list="author-names"
required="required"
value="<? if(isset($ebook->Authors) && sizeof($ebook->Authors) > 0){ ?><?= Formatter::EscapeHtml($ebook->Authors[0]->Name) ?><? } ?>"
/>
</label>
</fieldset>
<details>
<summary>Additional contributors</summary>
<fieldset>
<label class="icon user">
<span>Second author</span>
<input
type="text"
name="author-name-2"
list="author-names"
value="<? if(isset($ebook->Authors) && sizeof($ebook->Authors) > 1){ ?><?= Formatter::EscapeHtml($ebook->Authors[1]->Name) ?><? } ?>"
/>
</label>
<label class="icon user">
<span>Third author</span>
<input
type="text"
name="author-name-3"
list="author-names"
value="<? if(isset($ebook->Authors) && sizeof($ebook->Authors) > 2){ ?><?= Formatter::EscapeHtml($ebook->Authors[2]->Name) ?><? } ?>"
/>
</label>
<label class="icon user">
<span>Translator</span>
<datalist id="translator-names">
<? foreach(Contributor::GetAllTranslatorNames() as $translator){ ?>
<option value="<?= Formatter::EscapeHtml($translator->Name) ?>"><?= Formatter::EscapeHtml($translator->Name) ?></option>
<? } ?>
</datalist>
<input
type="text"
name="translator-name-1"
list="translator-names"
value="<? if(isset($ebook->Translators) && sizeof($ebook->Translators) > 0){ ?><?= Formatter::EscapeHtml($ebook->Translators[0]->Name) ?><? } ?>"
/>
</label>
<label class="icon user">
<span>Second translator</span>
<input
type="text"
name="translator-name-2"
list="translator-names"
value="<? if(isset($ebook->Translators) && sizeof($ebook->Translators) > 1){ ?><?= Formatter::EscapeHtml($ebook->Translators[1]->Name) ?><? } ?>"
/>
</label>
</fieldset>
</details>
<fieldset>
<legend>Ebook metadata</legend>
<label class="icon book">
<span>Title</span>
<input type="text" name="ebook-title" required="required"
value="<? if(isset($ebook->Title)){ ?><?= Formatter::EscapeHtml($ebook->Title) ?><? } ?>"/>
</label>
<fieldset>
<label class="icon year">
Year published
<input
type="text"
name="ebook-placeholder-year-published"
inputmode="numeric"
pattern="[0-9]{1,4}"
value="<? if(isset($ebook->EbookPlaceholder)){ ?><?= Formatter::EscapeHtml((string)$ebook->EbookPlaceholder->YearPublished) ?><? } ?>"
/>
</label>
</fieldset>
<label class="icon collection">
<span>Collection</span>
<datalist id="collection-names">
<? foreach(Collection::GetAll() as $collection){ ?>
<option value="<?= Formatter::EscapeHtml($collection->Name) ?>"><?= Formatter::EscapeHtml($collection->Name) ?></option>
<? } ?>
</datalist>
<input
type="text"
name="collection-name-1"
list="collection-names"
value="<? if(isset($ebook->CollectionMemberships)){ ?><?= Formatter::EscapeHtml($ebook->CollectionMemberships[0]->Collection->Name) ?><? } ?>"
/>
</label>
<fieldset>
<label class="icon ordered-list">
<span>Number in collection</span>
<input
type="text"
name="sequence-number-collection-name-1"
inputmode="numeric"
pattern="[0-9]{1,3}"
value="<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 0){ ?><?= Formatter::EscapeHtml((string)$ebook->CollectionMemberships[0]->SequenceNumber) ?><? } ?>"
/>
</label>
</fieldset>
</fieldset>
<details>
<summary>Additional collections</summary>
<fieldset>
<label class="icon collection">
<span>Second Collection</span>
<input
type="text"
name="collection-name-2"
list="collection-names"
value="<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 1){ ?><?= Formatter::EscapeHtml($ebook->CollectionMemberships[1]->Collection->Name) ?><? } ?>"
/>
</label>
<fieldset>
<label class="icon ordered-list">
<span>Number in collection</span>
<input
type="text"
name="sequence-number-collection-name-2"
inputmode="numeric"
pattern="[0-9]{1,3}"
value="<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 1){ ?><?= Formatter::EscapeHtml((string)$ebook->CollectionMemberships[1]->SequenceNumber) ?><? } ?>"
/>
</label>
</fieldset>
</fieldset>
<fieldset>
<label class="icon collection">
<span>Third Collection</span>
<input
type="text"
name="collection-name-3"
list="collection-names"
value="<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 2){ ?><?= Formatter::EscapeHtml($ebook->CollectionMemberships[2]->Collection->Name) ?><? } ?>"
/>
</label>
<fieldset>
<label class="icon ordered-list">
<span>Number in collection</span>
<input
type="text"
name="sequence-number-collection-name-3"
inputmode="numeric"
pattern="[0-9]{1,3}"
value="<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 2){ ?><?= Formatter::EscapeHtml((string)$ebook->CollectionMemberships[2]->SequenceNumber) ?><? } ?>"
/>
</label>
</fieldset>
</fieldset>
</details>
<fieldset>
<legend>Wanted list</legend>
<label class="controls-following-fieldset">
<span>On the wanted list?</span>
<input
type="checkbox"
name="ebook-placeholder-is-wanted"
<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->IsWanted){ ?>checked="checked"<? } ?>
/>
</label>
<fieldset>
<label>
<span>Did a Patron request this book?</span>
<input
type="checkbox"
name="ebook-placeholder-is-patron"
<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->IsPatron){ ?>checked="checked"<? } ?>
/>
</label>
<label class="icon meter">
<span>Difficulty</span>
<span>
<select name="ebook-placeholder-difficulty">
<option value=""></option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Beginner->value ?>"<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->Difficulty == Enums\EbookPlaceholderDifficulty::Beginner){ ?> selected="selected"<? } ?>>Beginner</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Intermediate->value ?>"<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->Difficulty == Enums\EbookPlaceholderDifficulty::Intermediate){ ?> selected="selected"<? } ?>>Intermediate</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Advanced->value ?>"<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->Difficulty == Enums\EbookPlaceholderDifficulty::Advanced){ ?> selected="selected"<? } ?>>Advanced</option>
</select>
</span>
</label>
<label class="icon hourglass">
<span>Wanted list status</span>
<span>
<select name="ebook-placeholder-status">
<option value="<?= Enums\EbookPlaceholderStatus::Wanted->value ?>"<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->Status == Enums\EbookPlaceholderStatus::Wanted){ ?> selected="selected"<? } ?>>Wanted</option>
<option value="<?= Enums\EbookPlaceholderStatus::InProgress->value ?>"<? if(isset($ebook->EbookPlaceholder) && $ebook->EbookPlaceholder->Status == Enums\EbookPlaceholderStatus::InProgress){ ?> selected="selected"<? } ?>>In progress</option>
</select>
</span>
</label>
<label>
<span>Transcription URL</span>
<input
type="url"
name="ebook-placeholder-transcription-url"
value="<? if(isset($ebook->EbookPlaceholder)){ ?><?= Formatter::EscapeHtml($ebook->EbookPlaceholder->TranscriptionUrl) ?><? } ?>"
/>
</label>
<label>
<span>Notes</span>
<span>Markdown accepted.</span>
<textarea maxlength="1024" name="ebook-placeholder-notes"><? if(isset($ebook->EbookPlaceholder)){ ?><?= Formatter::EscapeHtml($ebook->EbookPlaceholder->Notes) ?><? } ?></textarea>
</label>
</fieldset>
</fieldset>
<div class="footer">
<button>Submit</button>
</div>

View file

@ -9,12 +9,12 @@
$isAllSelected = sizeof($tags) == 0 || in_array('all', $tags);
?>
<form action="/ebooks" method="get" rel="search">
<label class="tags">Subjects
<label class="icon tags">Subjects
<select <? if(!Template::IsEreaderBrowser()){ ?> multiple="multiple"<? } ?> name="tags[]" size="1">
<option value="all">All</option>
<? foreach(EbookTag::GetAll() as $tag){ ?>
<option value="<?= $tag->UrlName ?>"<? if(!$isAllSelected && in_array($tag->UrlName, $tags)){ ?> selected="selected"<? } ?>><?= Formatter::EscapeHtml($tag->Name) ?></option>
<? } ?>
<? foreach(EbookTag::GetAll() as $tag){ ?>
<option value="<?= $tag->UrlName ?>"<? if(!$isAllSelected && in_array($tag->UrlName, $tags)){ ?> selected="selected"<? } ?>><?= Formatter::EscapeHtml($tag->Name) ?></option>
<? } ?>
</select>
</label>
<label>Keywords

View file

@ -15,7 +15,7 @@ $passwordAction = $passwordAction ?? Enums\PasswordActionType::None;
/>
</label>
<label class="user">
<label class="icon user">
Name
<input
type="text"
@ -135,6 +135,13 @@ $passwordAction = $passwordAction ?? Enums\PasswordActionType::None;
Can edit users
</label>
</li>
<li>
<label>
<input type="hidden" name="benefits-can-create-ebook-placeholder" value="false" />
<input type="checkbox" name="benefits-can-create-ebook-placeholder" value="true"<? if($user->Benefits->CanCreateEbookPlaceholders){ ?> checked="checked"<? } ?> />
Can create ebook placeholders
</label>
</li>
</ul>
</fieldset>

View file

@ -102,7 +102,15 @@ catch(Exceptions\InvalidPermissionsException){
</tr>
<tr>
<td>Tags</td>
<td><ul class="tags"><? foreach($artwork->Tags as $tag){ ?><li><a href="<?= $tag->Url ?>"><?= Formatter::EscapeHtml($tag->Name) ?></a></li><? } ?></ul></td>
<td>
<ul class="tags">
<? foreach($artwork->Tags as $tag){ ?>
<li>
<a href="<?= $tag->Url ?>"><?= Formatter::EscapeHtml($tag->Name) ?></a>
</li>
<? } ?>
</ul>
</td>
</tr>
<tr>
<td>Dimensions</td>

View file

@ -16,15 +16,14 @@ try{
throw new Exceptions\InvalidPermissionsException();
}
// We got here because an artwork was successfully submitted.
if($isCreated){
// We got here because an `Artwork` was successfully submitted.
http_response_code(Enums\HttpCode::Created->value);
$artwork = null;
session_unset();
}
// We got here because an artwork submission had errors and the user has to try again.
if($exception){
elseif($exception){
// We got here because an `Artwork` submission had errors and the user has to try again.
http_response_code(Enums\HttpCode::UnprocessableContent->value);
session_unset();
}

View file

@ -166,6 +166,10 @@ form[action^="/artworks/"]{
grid-column: 1 / span 4;
}
.year + label:has(input[type="checkbox"]){
margin-top: .5rem;
}
label.picture::before{
bottom: 2.6rem; /* Fix alignment caused by "circa" checkbox */
}
@ -226,17 +230,6 @@ form.create-update-artwork fieldset p:first-of-type{
margin-top: 0;
}
form.create-update-artwork legend{
font-size: 1.4rem;
font-family: "League Spartan", Arial, sans-serif;
margin-top: 2rem;
margin-bottom: 1rem;
line-height: 1.2;
letter-spacing: 1px;
text-transform: uppercase;
border-bottom: none;
}
form.create-update-artwork label{
display: block;
}

View file

@ -840,7 +840,8 @@ label:has(input[type="checkbox"]):focus-within,
select:focus,
button:focus,
nav a[rel]:focus,
textarea:focus{
textarea:focus,
summary:focus{
outline: 1px dashed var(--input-outline);
}
@ -1572,6 +1573,19 @@ ol.ebooks-list > li p.author a{
justify-content: center;
}
.placeholder-cover{
background-color: transparent;
border-radius: .25rem;
border: 2px dashed var(--sub-text);
height: 331px;
width: 221px;
}
ol.ebooks-list.list .placeholder-cover{
height: 191px;
width: 126px;
}
article nav ol,
main nav.pagination ol{
list-style: none;
@ -1790,12 +1804,9 @@ input[type="file"]{
label:has(input[type="email"]) input,
label:has(input[type="search"]) input,
label:has(input[type="url"]) input,
label.captcha input,
label.year input,
label.tags input,
label.picture input,
label.user input,
label:has(input[type="password"]) input{
label.icon input,
label:has(input[type="password"]) input,
label.icon select{
padding-left: 2.5rem;
}
@ -1850,8 +1861,8 @@ label:has(input[type="radio"]):has(> span) input,
label:has(input[type="checkbox"]):has(> span) input{
grid-row: 1 / span 2;
justify-self: center;
align-self: start;
margin-top: 10px;
align-self: center;
margin: 0;
margin-right: .5rem;
}
@ -1895,11 +1906,7 @@ label:has(select) > span + span,
label:has(input[type="email"]),
label:has(input[type="search"]),
label:has(input[type="url"]),
label.captcha,
label.year,
label.tags,
label.picture,
label.user,
label.icon,
label:has(input[type="password"]){
display: block;
position: relative;
@ -1908,11 +1915,7 @@ label:has(input[type="password"]){
label:has(input[type="email"])::before,
label:has(input[type="search"])::before,
label.captcha::before,
label.year::before,
label.tags::before,
label.picture::before,
label.user::before,
label.icon::before,
label:has(input[type="url"])::before,
label:has(input[type="password"])::before{
display: block;
@ -1954,6 +1957,26 @@ label.year::before{
content: "\f073";
}
label.meter::before{
content: "\f0e4";
}
label.collection::before{
content: "\f0e8";
}
label.book::before{
content: "\f02d";
}
label.ordered-list::before{
content: "\f0cb";
}
label.hourglass::before{
content: "\f254";
}
label.tags:not(:has(select))::before{
content: "\f02c";
}
@ -2489,15 +2512,37 @@ form[action="/newsletter/subscriptions"] fieldset{
fieldset{
padding: 0;
margin: 0;
border: none;
}
fieldset p,
fieldset legend{
label.controls-following-fieldset + fieldset,
details summary ~ *{
margin-left: 1rem;
}
fieldset p{
border-bottom: 1px dashed var(--input-border);
}
fieldset legend,
fieldset legend{
font-size: 1.4rem;
font-family: "League Spartan", Arial, sans-serif;
margin-top: 2rem;
margin-bottom: 1rem;
line-height: 1.2;
letter-spacing: 1px;
text-transform: uppercase;
}
summary{
cursor: pointer;
}
summary + fieldset{
margin-top: 1rem;
}
fieldset p:has(+ ul){
font-weight: bold;
}
@ -2604,8 +2649,8 @@ aside header{
font-size: 1.5rem;
}
.meter,
.progress{
div.meter,
div.progress{
position: relative;
font-size: 0;
}
@ -2677,13 +2722,13 @@ aside header{
border-top: 1px solid #000;
}
.progress + p{
div.progress + p{
margin-top: 2rem;
hyphens: auto;
}
.meter p,
.progress p{
div.meter p,
div.progress p{
font-size: 1rem;
font-family: "League Spartan", Arial, sans-serif;
left: 0;
@ -2701,7 +2746,7 @@ aside header{
margin-top: 0;
}
.progress p.start{
div.progress p.start{
margin-right: auto;
margin-left: 1rem;
border: none;
@ -2710,7 +2755,7 @@ aside header{
font-size: .75rem;
}
.progress p.target{
div.progress p.target{
margin-left: auto;
margin-right: 1rem;
border: none;
@ -2719,8 +2764,8 @@ aside header{
font-size: .75rem;
}
.meter > div,
.progress > div{
div.meter > div,
div.progress > div{
position: absolute;
width: 100%;
height: 100%;
@ -3121,6 +3166,74 @@ form[action="/settings"] label{
display: inline-block;
}
ol.ebooks-list > li a.wanted,
ol.ebooks-list > li.wanted .placeholder-cover{
position: relative;
}
ol.ebooks-list > li.wanted .placeholder-cover::before,
ol.ebooks-list > li.wanted a::after{
/* Ribbon */
font-family: "League Spartan", sans-serif;
position: absolute;
left: -.5rem;
top: calc(.25rem + 3px);
padding: .5rem 1rem .5rem .5rem;
line-height: 1;
color: #ffffff;
text-shadow: 1px 1px 0 rgba(0, 0, 0, .75);
text-transform: uppercase;
font-size: .5rem;
white-space: nowrap;
font-weight: bold;
clip-path: polygon(0 0, 100% 0, 85% 100%, 0 100%);
z-index: 2;
}
ol.ebooks-list > li[property="schema:hasPart"][value].wanted .placeholder-cover::before,
ol.ebooks-list > li[property="schema:hasPart"][value].wanted a::after{
top: calc(.25rem + 3px + 2rem);
}
ol.ebooks-list > li.wanted .placeholder-cover::after{
/* Ribbon bottom wrap-around */
content: "";
position: absolute;
top: calc(.5rem + .5rem + .5rem + .5rem - 2px);
height: .5rem;
width: .5rem;
left: -.5rem;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 0);
z-index: 1;
filter: brightness(50%);
}
ol.ebooks-list > li[property="schema:hasPart"][value].wanted .placeholder-cover::after{
top: calc(.25rem + 3px + 2rem + .5rem + .5rem + .5rem);
}
ol.ebooks-list > li.wanted a::after{
/* Ribbon shadow */
left: calc(-.5rem + 4px);
top: calc(.5rem + 2px);
background: rgba(0, 0, 0, .4);
z-index: auto;
}
ol.ebooks-list > li[property="schema:hasPart"][value].wanted a::after{
top: calc(.25rem + 3px + 2rem + 4px);
}
ol.ebooks-list > li.wanted .placeholder-cover::before,
ol.ebooks-list > li.wanted .placeholder-cover::after{
background: #861d1d;
}
ol.ebooks-list > li.wanted .placeholder-cover::before,
ol.ebooks-list > li.wanted a::after{
content: "wanted";
}
@media (hover: none) and (pointer: coarse){ /* target ipads and smartphones without a mouse */
/* For iPad, unset the height so it matches the other elements */
select[multiple]{
@ -3717,13 +3830,13 @@ form[action="/settings"] label{
line-height: 1.4;
}
.progress p.start,
.progress p.target,
.progress p.stretch-base{
div.progress p.start,
div.progress p.target,
div.progress p.stretch-base{
display: none;
}
.progress p{
div.progress p{
border: none;
background: transparent;
}

View file

@ -0,0 +1,92 @@
form.create-update-ebook-placeholder fieldset{
display: grid;
gap: 2rem;
}
form.create-update-ebook-placeholder details + fieldset,
form.create-update-ebook-placeholder fieldset + fieldset{
margin-top: 2rem;
}
form.create-update-ebook-placeholder > fieldset:nth-of-type(1),
form.create-update-ebook-placeholder details:nth-of-type(1) fieldset{
grid-template-columns: 1fr 1fr;
}
form.create-update-ebook-placeholder > fieldset:nth-of-type(2),
form.create-update-ebook-placeholder details:nth-of-type(2) fieldset{
grid-template-columns: 1fr 200px;
}
form.create-update-ebook-placeholder fieldset label:has(input[name="ebook-placeholder-transcription-url"]),
form.create-update-ebook-placeholder fieldset label:has(textarea[name="ebook-placeholder-notes"]){
grid-column: 1 / span 2;
}
form.create-update-ebook-placeholder fieldset fieldset:has(input[name="sequence-number-collection-name-1"]),
form.create-update-ebook-placeholder fieldset fieldset:has(input[name="sequence-number-collection-name-2"]),
form.create-update-ebook-placeholder fieldset fieldset:has(input[name="sequence-number-collection-name-3"]),
form.create-update-ebook-placeholder fieldset fieldset:has(input[name="ebook-placeholder-year-published"]){
display: grid;
grid-template-columns: 200px 1fr;
gap: 2rem;
}
form.create-update-ebook-placeholder fieldset label:has(input[type="checkbox"]){
grid-column: 1 / span 2;
}
form.create-update-ebook-placeholder details{
margin-top: 1rem;
}
form.create-update-ebook-placeholder summary{
font-style: italic;
}
form.create-update-ebook-placeholder fieldset p{
font-style: italic;
margin: 0;
border: none;
}
form.create-update-ebook-placeholder fieldset p:first-of-type{
margin-top: 0;
}
form.create-update-ebook-placeholder legend{
font-size: 1.4rem;
font-family: "League Spartan", Arial, sans-serif;
margin-bottom: 1rem;
line-height: 1.2;
letter-spacing: 1px;
text-transform: uppercase;
}
form.create-update-ebook-placeholder label{
display: block;
}
form div.footer{
margin-top: 1rem;
text-align: right;
}
/* Hide the next fieldset unless the ebook-placeholder-is-wanted checkbox is checked. */
form.create-update-ebook-placeholder fieldset:has(input[name="ebook-placeholder-is-wanted"]) fieldset{
display: none;
grid-column: 1 / span 2;
}
form.create-update-ebook-placeholder fieldset:has(input[name="ebook-placeholder-is-wanted"]:checked) fieldset{
display: grid;
}
article.ebook.ebook-placeholder > header{
border: 2px dashed var(--sub-text);
border-top: 0;
}
article.ebook.ebook-placeholder .placeholder-details{
padding-top: 2rem;
}

View file

@ -349,7 +349,6 @@ code.full .utf{
}
.step-by-step-guide summary{
cursor: pointer;
/* reproduce H2 styling: */
font-size: 1.4rem;
font-family: "League Spartan", Arial, sans-serif;

View file

@ -0,0 +1,88 @@
<?
use function Safe\preg_replace;
/** @var string $identifier Passed from script this is included from. */
$ebook = null;
try{
$ebook = Ebook::GetByIdentifier($identifier);
}
catch(Exceptions\EbookNotFoundException){
Template::Emit404();
}
?><?= Template::Header(
[
'title' => strip_tags($ebook->TitleWithCreditsHtml),
'css' => ['/css/ebook-placeholder.css'],
'highlight' => 'ebooks',
'canonicalUrl' => SITE_URL . $ebook->Url
])
?>
<main>
<article class="ebook ebook-placeholder" typeof="schema:Book" about="<?= $ebook->Url ?>">
<header>
<hgroup>
<h1 property="schema:name"><?= Formatter::EscapeHtml($ebook->Title) ?></h1>
<? foreach($ebook->Authors as $author){ ?>
<?
/* We include the `resource` attr here because we can have multiple authors, and in that case their href URLs will link to their combined corpus.
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>
<? } ?>
<? } ?>
</hgroup>
</header>
<aside id="reading-ease">
<? if($ebook->ContributorsHtml != ''){ ?>
<p><?= $ebook->ContributorsHtml ?></p>
<? } ?>
<? if(sizeof($ebook->CollectionMemberships) > 0){ ?>
<? foreach($ebook->CollectionMemberships as $collectionMembership){ ?>
<? $collection = $collectionMembership->Collection; ?>
<? $sequenceNumber = $collectionMembership->SequenceNumber; ?>
<p>
<? if($sequenceNumber !== null){ ?>№ <?= number_format($sequenceNumber) ?> in the<? }else{ ?>Part of the<? } ?> <a href="<?= $collection->Url ?>" property="schema:isPartOf"><?= Formatter::EscapeHtml(preg_replace('/^The /ius', '', (string)$collection->Name)) ?></a>
<? if($collection->Type !== null){ ?>
<? if(substr_compare(mb_strtolower($collection->Name), mb_strtolower($collection->Type->value), -strlen(mb_strtolower($collection->Type->value))) !== 0){ ?>
<?= $collection->Type->value ?>.
<? } ?>
<? }else{ ?>
collection.
<? } ?>
</p>
<? } ?>
<? } ?>
</aside>
<section class="placeholder-details">
<? if($ebook->EbookPlaceholder->IsPublicDomain){ ?>
<p>We dont have this ebook in our catalog yet.</p>
<p>You can <a href="/donate#sponsor-an-ebook">sponsor the production of this ebook</a> and well get working on it immediately!</p>
<? }elseif($ebook->EbookPlaceholder->YearPublished !== null){ ?>
<p>This book was published in <?= $ebook->EbookPlaceholder->YearPublished ?>, and will therefore enter the U.S. public domain on <b>January 1, <?= $ebook->EbookPlaceholder->YearPublished + 96 ?>.</b></p>
<p>We cant work on it any earlier than that.</p>
<? }else{ ?>
<p>This book is not yet in the U.S. public domain. We cant offer it until it is.</p>
<? } ?>
</section>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,62 @@
<?
use function Safe\session_unset;
session_start();
$isCreated = HttpInput::Bool(SESSION, 'is-ebook-placeholder-created') ?? false;
$exception = HttpInput::SessionObject('exception', Exceptions\AppException::class);
$ebook = HttpInput::SessionObject('ebook', Ebook::class);
try{
if(Session::$User === null){
throw new Exceptions\LoginRequiredException();
}
if(!Session::$User->Benefits->CanCreateEbookPlaceholders){
throw new Exceptions\InvalidPermissionsException();
}
if($isCreated){
// We got here because an `Ebook` was successfully created.
http_response_code(Enums\HttpCode::Created->value);
$createdEbook = $ebook;
$ebook = null;
session_unset();
}
elseif($exception){
// We got here because an `Ebook` submission had errors and the user has to try again.
http_response_code(Enums\HttpCode::UnprocessableContent->value);
session_unset();
}
}
catch(Exceptions\LoginRequiredException){
Template::RedirectToLogin();
}
catch(Exceptions\InvalidPermissionsException){
Template::Emit403(); // No permissions to create an ebook placeholder.
}
?>
<?= Template::Header(
[
'title' => 'Create an Ebook Placeholder',
'css' => ['/css/ebook-placeholder.css'],
'highlight' => '',
'description' => 'Create a placeholder for an ebook not yet in the collection.'
]
) ?>
<main>
<section class="narrow">
<h1>Create an Ebook Placeholder</h1>
<?= Template::Error(['exception' => $exception]) ?>
<? if($isCreated && isset($createdEbook)){ ?>
<p class="message success">Ebook Placeholder created: <a href="<?= $createdEbook->Url ?>"><?= Formatter::EscapeHtml($createdEbook->Title) ?></a></p>
<? } ?>
<form class="create-update-ebook-placeholder" method="<?= Enums\HttpMethod::Post->value ?>" action="/ebook-placeholders" autocomplete="off">
<?= Template::EbookPlaceholderForm(['ebook' => $ebook]) ?>
</form>
</section>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,111 @@
<?
try{
session_start();
$httpMethod = HttpInput::ValidateRequestMethod([Enums\HttpMethod::Post]);
$exceptionRedirectUrl = '/ebook-placeholders/new';
if(Session::$User === null){
throw new Exceptions\LoginRequiredException();
}
// POSTing a new ebook placeholder.
if($httpMethod == Enums\HttpMethod::Post){
if(!Session::$User->Benefits->CanCreateEbookPlaceholders){
throw new Exceptions\InvalidPermissionsException();
}
$ebook = new Ebook();
$title = HttpInput::Str(POST, 'ebook-title');
if(isset($title)){
$ebook->Title = $title;
}
$authors = [];
$authorFields = ['author-name-1', 'author-name-2', 'author-name-3'];
foreach($authorFields as $authorField){
$authorName = HttpInput::Str(POST, $authorField);
if(!isset($authorName)){
continue;
}
$author = new Contributor();
$author->Name = $authorName;
$author->UrlName = Formatter::MakeUrlSafe($author->Name);
$author->MarcRole = Enums\MarcRole::Author;
$authors[] = $author;
}
$ebook->Authors = $authors;
$translators = [];
$translatorFields = ['translator-name-1', 'translator-name-2'];
foreach($translatorFields as $translatorField){
$translatorName = HttpInput::Str(POST, $translatorField);
if(!isset($translatorName)){
continue;
}
$translator = new Contributor();
$translator->Name = $translatorName;
$translator->UrlName = Formatter::MakeUrlSafe($translator->Name);
$translator->MarcRole = Enums\MarcRole::Translator;
$translators[] = $translator;
}
$ebook->Translators = $translators;
$collectionMemberships = [];
$collectionNameFields = ['collection-name-1', 'collection-name-2', 'collection-name-3'];
foreach($collectionNameFields as $collectionNameField){
$collectionName = HttpInput::Str(POST, $collectionNameField);
if(!isset($collectionName)){
continue;
}
$collectionSequenceNumber = HttpInput::Int(POST, 'sequence-number-' . $collectionNameField);
$collection = Collection::FromName($collectionName);
$cm = new CollectionMembership();
$cm->Collection = $collection;
$cm->SequenceNumber = $collectionSequenceNumber;
$collectionMemberships[] = $cm;
}
$ebook->CollectionMemberships = $collectionMemberships;
$ebookPlaceholder = new EbookPlaceholder();
$ebookPlaceholder->FillFromHttpPost();
$ebook->EbookPlaceholder = $ebookPlaceholder;
$ebook->FillIdentifierFromTitleAndContributors();
try{
$existingEbook = Ebook::GetByIdentifier($ebook->Identifier);
throw new Exceptions\DuplicateEbookException($ebook->Identifier);
}
catch(Exceptions\EbookNotFoundException){
// Pass and create the placeholder. There is no existing ebook with this identifier.
}
// These properties must be set before calling `Ebook::Create()` to prevent the getters from triggering DB queries or accessing `Ebook::$EbookId` before it is set.
$ebook->Tags = [];
$ebook->LocSubjects = [];
$ebook->Illustrators = [];
$ebook->Contributors = [];
$ebook->Create();
$_SESSION['ebook'] = $ebook;
$_SESSION['is-ebook-placeholder-created'] = true;
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: /ebook-placeholders/new');
}
}
catch(Exceptions\LoginRequiredException){
Template::RedirectToLogin();
}
catch(Exceptions\InvalidPermissionsException | Exceptions\InvalidHttpMethodException | Exceptions\HttpMethodNotAllowedException){
Template::Emit403();
}
catch(Exceptions\AppException $ex){
$_SESSION['ebook'] = $ebook;
$_SESSION['exception'] = $ex;
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: ' . $exceptionRedirectUrl);
}

View file

@ -13,6 +13,10 @@ try{
$identifier = EBOOKS_IDENTIFIER_PREFIX . $urlPath;
$ebook = Ebook::GetByIdentifier($identifier);
if($ebook->IsPlaceholder()){
throw new Exceptions\InvalidFileException();
}
$format = Enums\EbookFormatType::tryFrom(HttpInput::Str(GET, 'format') ?? '') ?? Enums\EbookFormatType::Epub;
switch($format){
case Enums\EbookFormatType::Kepub:

View file

@ -19,6 +19,11 @@ try{
$ebook = Ebook::GetByIdentifier($identifier);
if($ebook->IsPlaceholder()){
require('/standardebooks.org/web/www/ebook-placeholders/get.php');
exit();
}
// Divide our sources into transcriptions and scans.
foreach($ebook->Sources as $source){
switch($source->Type){

View file

@ -34,7 +34,7 @@ try{
$tags = [];
}
$result = Ebook::GetAllByFilter($query != '' ? $query : null, $tags, $sort, $page, $perPage);
$result = Ebook::GetAllByFilter($query != '' ? $query : null, $tags, $sort, $page, $perPage, Enums\EbookReleaseStatusFilter::All);
$ebooks = $result['ebooks'];
$totalEbooks = $result['ebooksCount'];
$pageTitle = 'Browse Standard Ebooks';

View file

@ -7,7 +7,7 @@ try{
$count = HttpInput::Int(GET, 'per-page') ?? EBOOKS_PER_PAGE;
if($query !== ''){
$ebooks = Ebook::GetAllByFilter($query, [], Enums\EbookSortType::Newest, $startPage, $count)['ebooks'];
$ebooks = Ebook::GetAllByFilter($query, [], Enums\EbookSortType::Newest, $startPage, $count, Enums\EbookReleaseStatusFilter::Released)['ebooks'];
}
}
catch(\Exception){

View file

@ -7,7 +7,7 @@ try{
$count = HttpInput::Int(GET, 'per-page') ?? EBOOKS_PER_PAGE;
if($query !== ''){
$ebooks = Ebook::GetAllByFilter($query, [], Enums\EbookSortType::Newest, $startPage, $count)['ebooks'];
$ebooks = Ebook::GetAllByFilter($query, [], Enums\EbookSortType::Newest, $startPage, $count, Enums\EbookReleaseStatusFilter::Released)['ebooks'];
}
}
catch(\Exception){

View file

@ -7,7 +7,7 @@ try{
$count = HttpInput::Int(GET, 'per-page') ?? EBOOKS_PER_PAGE;
if($query !== ''){
$ebooks = Ebook::GetAllByFilter($query, [], Enums\EbookSortType::Newest, $startPage, $count)['ebooks'];
$ebooks = Ebook::GetAllByFilter($query, [], Enums\EbookSortType::Newest, $startPage, $count, Enums\EbookReleaseStatusFilter::Released)['ebooks'];
}
}
catch(\Exception){

Binary file not shown.

View file

@ -36,7 +36,7 @@ if($exception){
<label>Your email address
<input type="email" name="email" value="<?= Formatter::EscapeHtml($subscription->User->Email ?? '') ?>" maxlength="80" required="required" />
</label>
<label class="captcha">
<label class="icon captcha">
Type the letters in the <abbr class="acronym">CAPTCHA</abbr> image
<div>
<input type="text" name="captcha" required="required" autocomplete="off" />

View file

@ -169,6 +169,10 @@ catch(Exceptions\SeeOtherException $ex){
<td>Can edit users:</td>
<td><? if($user->Benefits->CanEditUsers){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<tr>
<td>Can create ebook placeholders:</td>
<td><? if($user->Benefits->CanCreateEbookPlaceholders){ ?>☑<? }else{ ?>☐<? } ?></td>
</tr>
<? } ?>
</tbody>
</table>