Add beginning of a project management system to placeholders

This commit is contained in:
Alex Cabal 2024-12-14 19:03:04 -06:00
parent e56de4b19d
commit adfe07aad9
42 changed files with 717 additions and 118 deletions

View file

@ -10,6 +10,8 @@ CREATE TABLE IF NOT EXISTS `Benefits` (
`CanEditCollections` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanEditCollections` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanEditEbooks` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanEditEbooks` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanCreateEbookPlaceholders` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanCreateEbookPlaceholders` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanManageProjects` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanReviewProjects` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`UserId`), PRIMARY KEY (`UserId`),
KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`) KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS `Projects` (
`ProjectId` int(10) unsigned NOT NULL AUTO_INCREMENT, `ProjectId` int(10) unsigned NOT NULL AUTO_INCREMENT,
`Status` enum('in_progress','stalled','completed','abandoned') NOT NULL DEFAULT 'in_progress', `Status` enum('in_progress','stalled','completed','abandoned') NOT NULL DEFAULT 'in_progress',
`EbookId` int(11) NOT NULL, `EbookId` int(11) NOT NULL,
`ProducerName` varchar(151) DEFAULT NULL, `ProducerName` varchar(151) NOT NULL DEFAULT '',
`ProducerEmail` varchar(80) DEFAULT NULL, `ProducerEmail` varchar(80) DEFAULT NULL,
`DiscussionUrl` varchar(255) DEFAULT NULL, `DiscussionUrl` varchar(255) DEFAULT NULL,
`VcsUrl` varchar(255) NOT NULL, `VcsUrl` varchar(255) NOT NULL,

View file

@ -109,10 +109,10 @@ class Collection{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidCollectionException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidCollectionException();
if(isset($this->Name)){ if(isset($this->Name)){
$this->Name = trim($this->Name); $this->Name = trim($this->Name);
@ -154,7 +154,7 @@ class Collection{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidCollectionException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();
@ -169,7 +169,7 @@ class Collection{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidCollectionException
*/ */
public function GetByUrlNameOrCreate(string $urlName): Collection{ public function GetByUrlNameOrCreate(string $urlName): Collection{
$result = Db::Query(' $result = Db::Query('

View file

@ -64,10 +64,10 @@ class Contributor{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidContributorException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidContributorException();
if(!isset($this->EbookId)){ if(!isset($this->EbookId)){
$error->Add(new Exceptions\ContributorEbookIdRequiredException()); $error->Add(new Exceptions\ContributorEbookIdRequiredException());
@ -140,7 +140,7 @@ class Contributor{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidContributorException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();

View file

@ -42,6 +42,8 @@ use function Safe\shell_exec;
* @property string $TextSinglePageSizeFormatted * @property string $TextSinglePageSizeFormatted
* @property string $IndexableText * @property string $IndexableText
* @property ?EbookPlaceholder $EbookPlaceholder * @property ?EbookPlaceholder $EbookPlaceholder
* @property array<Project> $Projects
* @property ?Project $ProjectInProgress
*/ */
class Ebook{ class Ebook{
use Traits\Accessor; use Traits\Accessor;
@ -118,11 +120,48 @@ class Ebook{
protected string $_TextSinglePageSizeFormatted; protected string $_TextSinglePageSizeFormatted;
protected string $_IndexableText; protected string $_IndexableText;
protected ?EbookPlaceholder $_EbookPlaceholder = null; protected ?EbookPlaceholder $_EbookPlaceholder = null;
/** @var array<Project> $_Projects */
protected array $_Projects;
protected ?Project $_ProjectInProgress;
// ******* // *******
// GETTERS // GETTERS
// ******* // *******
/**
* @return array<Project>
*/
protected function GetProjects(): array{
if(!isset($this->_Projects)){
$this->_Projects = Db::Query('
SELECT *
from Projects
where EbookId = ?
order by Created desc
', [$this->EbookId], Project::class);
}
return $this->_Projects;
}
protected function GetProjectInProgress(): ?Project{
if(!isset($this->_ProjectInProgress)){
if(!isset($this->EbookId)){
$this->_ProjectInProgress = null;
}
else{
$this->_ProjectInProgress = Db::Query('
SELECT *
from Projects
where EbookId = ?
and Status in (?, ?)
', [$this->EbookId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled], Project::class)[0] ?? null;
}
}
return $this->_ProjectInProgress;
}
/** /**
* @return array<GitCommit> * @return array<GitCommit>
*/ */
@ -1048,10 +1087,10 @@ class Ebook{
// ******* // *******
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidEbookException();
if(isset($this->Identifier)){ if(isset($this->Identifier)){
$this->Identifier = trim($this->Identifier); $this->Identifier = trim($this->Identifier);
@ -1335,17 +1374,13 @@ class Ebook{
$error->Add(new Exceptions\EbookMissingPlaceholderException()); $error->Add(new Exceptions\EbookMissingPlaceholderException());
} }
if(!$this->IsPlaceholder() && isset($this->EbookPlaceholder)){
$error->Add(new Exceptions\EbookUnexpectedPlaceholderException());
}
if($error->HasExceptions){ if($error->HasExceptions){
throw $error; throw $error;
} }
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookException
* @throws Exceptions\DuplicateEbookException * @throws Exceptions\DuplicateEbookException
*/ */
public function CreateOrUpdate(): void{ public function CreateOrUpdate(): void{
@ -1360,7 +1395,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookTagException
*/ */
private function CreateTags(): void{ private function CreateTags(): void{
$tags = []; $tags = [];
@ -1371,7 +1406,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidLocSubjectException
*/ */
private function CreateLocSubjects(): void{ private function CreateLocSubjects(): void{
$subjects = []; $subjects = [];
@ -1382,7 +1417,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidCollectionException
*/ */
private function CreateCollections(): void{ private function CreateCollections(): void{
$collectionMemberships = []; $collectionMemberships = [];
@ -1633,7 +1668,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookException
* @throws Exceptions\DuplicateEbookException If an `Ebook` with the given identifier already exists. * @throws Exceptions\DuplicateEbookException If an `Ebook` with the given identifier already exists.
*/ */
public function Create(): void{ public function Create(): void{
@ -1647,9 +1682,16 @@ class Ebook{
// Pass. // Pass.
} }
$this->CreateTags(); try{
$this->CreateLocSubjects(); $this->CreateTags();
$this->CreateCollections(); $this->CreateLocSubjects();
$this->CreateCollections();
}
catch(Exceptions\ValidationException $ex){
$error = new Exceptions\InvalidEbookException();
$error->Add($ex);
throw $error;
}
Db::Query(' Db::Query('
INSERT into Ebooks (Identifier, WwwFilesystemPath, RepoFilesystemPath, KindleCoverUrl, EpubUrl, INSERT into Ebooks (Identifier, WwwFilesystemPath, RepoFilesystemPath, KindleCoverUrl, EpubUrl,
@ -1687,25 +1729,39 @@ class Ebook{
$this->EbookId = Db::GetLastInsertedId(); $this->EbookId = Db::GetLastInsertedId();
$this->AddTags(); try{
$this->AddLocSubjects(); $this->AddTags();
$this->AddCollectionMemberships(); $this->AddLocSubjects();
$this->AddGitCommits(); $this->AddCollectionMemberships();
$this->AddSources(); $this->AddGitCommits();
$this->AddContributors(); $this->AddSources();
$this->AddTocEntries(); $this->AddContributors();
$this->AddEbookPlaceholder(); $this->AddTocEntries();
$this->AddEbookPlaceholder();
}
catch(Exceptions\ValidationException $ex){
$error = new Exceptions\InvalidEbookException();
$error->Add($ex);
throw $error;
}
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookException
*/ */
public function Save(): void{ public function Save(): void{
$this->Validate(); $this->Validate();
$this->CreateTags(); try{
$this->CreateLocSubjects(); $this->CreateTags();
$this->CreateCollections(); $this->CreateLocSubjects();
$this->CreateCollections();
}
catch(Exceptions\ValidationException $ex){
$error = new Exceptions\InvalidEbookException();
$error->Add($ex);
throw $error;
}
Db::Query(' Db::Query('
UPDATE Ebooks UPDATE Ebooks
@ -1742,29 +1798,37 @@ class Ebook{
$this->EbookCreated, $this->EbookUpdated, $this->TextSinglePageByteCount, $this->IndexableText, $this->EbookCreated, $this->EbookUpdated, $this->TextSinglePageByteCount, $this->IndexableText,
$this->EbookId]); $this->EbookId]);
$this->RemoveTags();
$this->AddTags();
$this->RemoveLocSubjects(); try{
$this->AddLocSubjects(); $this->RemoveTags();
$this->AddTags();
$this->RemoveCollectionMemberships(); $this->RemoveLocSubjects();
$this->AddCollectionMemberships(); $this->AddLocSubjects();
$this->RemoveGitCommits(); $this->RemoveCollectionMemberships();
$this->AddGitCommits(); $this->AddCollectionMemberships();
$this->RemoveSources(); $this->RemoveGitCommits();
$this->AddSources(); $this->AddGitCommits();
$this->RemoveContributors(); $this->RemoveSources();
$this->AddContributors(); $this->AddSources();
$this->RemoveTocEntries(); $this->RemoveContributors();
$this->AddTocEntries(); $this->AddContributors();
$this->RemoveEbookPlaceholder(); $this->RemoveTocEntries();
$this->AddEbookPlaceholder(); $this->AddTocEntries();
$this->RemoveEbookPlaceholder();
$this->AddEbookPlaceholder();
}
catch(Exceptions\ValidationException $ex){
$error = new Exceptions\InvalidEbookException();
$error->Add($ex);
throw $error;
}
} }
private function RemoveTags(): void{ private function RemoveTags(): void{
@ -1854,7 +1918,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidGitCommitException
*/ */
private function AddGitCommits(): void{ private function AddGitCommits(): void{
foreach($this->GitCommits as $commit){ foreach($this->GitCommits as $commit){
@ -1872,7 +1936,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidSourceException
*/ */
private function AddSources(): void{ private function AddSources(): void{
foreach($this->Sources as $sortOrder => $source){ foreach($this->Sources as $sortOrder => $source){
@ -1891,7 +1955,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidContributorException
*/ */
private function AddContributors(): void{ private function AddContributors(): void{
$allContributors = array_merge($this->Authors, $this->Illustrators, $this->Translators, $this->Contributors); $allContributors = array_merge($this->Authors, $this->Illustrators, $this->Translators, $this->Contributors);
@ -1932,7 +1996,7 @@ class Ebook{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookPlaceholderException
*/ */
private function AddEbookPlaceholder(): void{ private function AddEbookPlaceholder(): void{
if(isset($this->EbookPlaceholder)){ if(isset($this->EbookPlaceholder)){

View file

@ -73,11 +73,11 @@ class EbookPlaceholder{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookPlaceholderException
*/ */
public function Validate(): void{ public function Validate(): void{
$thisYear = intval(NOW->format('Y')); $thisYear = intval(NOW->format('Y'));
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidEbookPlaceholderException();
if(isset($this->YearPublished) && ($this->YearPublished <= 0 || $this->YearPublished > $thisYear)){ if(isset($this->YearPublished) && ($this->YearPublished <= 0 || $this->YearPublished > $thisYear)){
$error->Add(new Exceptions\InvalidEbookPlaceholderYearPublishedException()); $error->Add(new Exceptions\InvalidEbookPlaceholderYearPublishedException());
@ -99,7 +99,7 @@ class EbookPlaceholder{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookPlaceholderException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();
@ -117,4 +117,13 @@ class EbookPlaceholder{
', [$this->EbookId, $this->YearPublished, $this->Difficulty, $this->TranscriptionUrl, ', [$this->EbookId, $this->YearPublished, $this->Difficulty, $this->TranscriptionUrl,
$this->IsWanted, $this->IsInProgress, $this->IsPatron, $this->Notes]); $this->IsWanted, $this->IsInProgress, $this->IsPatron, $this->Notes]);
} }
public function Delete(): void{
Db::Query('
DELETE
from EbookPlaceholders
where EbookId = ?
',
[$this->EbookId]);
}
} }

View file

@ -14,10 +14,10 @@ class EbookSource{
// ******* // *******
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidSourceException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidSourceException();
if(!isset($this->EbookId)){ if(!isset($this->EbookId)){
$error->Add(new Exceptions\EbookSourceEbookIdRequiredException()); $error->Add(new Exceptions\EbookSourceEbookIdRequiredException());
@ -44,7 +44,7 @@ class EbookSource{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidSourceException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();

View file

@ -23,10 +23,10 @@ class EbookTag extends Tag{
// ******* // *******
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookTagException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidEbookTagException();
if(isset($this->Name)){ if(isset($this->Name)){
$this->Name = trim($this->Name); $this->Name = trim($this->Name);
@ -55,7 +55,7 @@ class EbookTag extends Tag{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookTagException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();
@ -75,7 +75,7 @@ class EbookTag extends Tag{
// *********** // ***********
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidEbookTagException
*/ */
public function GetByNameOrCreate(string $name): EbookTag{ public function GetByNameOrCreate(string $name): EbookTag{
$result = Db::Query(' $result = Db::Query('

View file

@ -0,0 +1,16 @@
<?
namespace Enums;
enum ProjectStatusType: string{
case InProgress = 'in_progress';
case Stalled = 'stalled';
case Completed = 'completed';
case Abandoned = 'abandoned';
public function GetDisplayName(): string{
return match($this){
self::InProgress => 'in progress',
default => $this->value
};
}
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class EbookIsNotAPlaceholderException extends AppException{
/** @var string $message */
protected $message = 'This projects ebook is already released, and not a placeholder.';
}

View file

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

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidCollectionException extends ValidationException{
/** @var string $message */
protected $message = 'Collection is invalid.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidContributorException extends ValidationException{
/** @var string $message */
protected $message = 'Contributor is invalid.';
}

View file

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

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidEbookPlaceholderException extends ValidationException{
/** @var string $message */
protected $message = 'Ebook placeholder is invalid.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidEbookTagException extends ValidationException{
/** @var string $message */
protected $message = 'Ebook tag is invalid.';
}

View file

@ -1,5 +1,7 @@
<? <?
namespace Exceptions; namespace Exceptions;
class InvalidGitCommitException extends AppException{ class InvalidGitCommitException extends ValidationException{
/** @var string $message */
protected $message = 'Git commit is invalid.';
} }

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidLocSubjectException extends ValidationException{
/** @var string $message */
protected $message = 'LoC Subject is invalid.';
}

View file

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

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidSourceException extends ValidationException{
/** @var string $message */
protected $message = 'Source is invalid.';
}

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class InvalidVcsUrlException extends InvalidUrlException{
/** @var string $message */
protected $message = 'Invalid VCS URL.';
}

View file

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

View file

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

View file

@ -0,0 +1,7 @@
<?
namespace Exceptions;
class ProjectExistsException extends AppException{
/** @var string $message */
protected $message = 'An active project already exists for this ebook.';
}

View file

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

View file

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

View file

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

View file

@ -37,10 +37,10 @@ class GitCommit{
// ******* // *******
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidGitCommitException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidGitCommitException();
if(!isset($this->EbookId)){ if(!isset($this->EbookId)){
$error->Add(new Exceptions\GitCommitEbookIdRequiredException()); $error->Add(new Exceptions\GitCommitEbookIdRequiredException());
@ -83,7 +83,7 @@ class GitCommit{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidGitCommitException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();

View file

@ -8,10 +8,10 @@ class LocSubject{
// ******* // *******
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidLocSubjectException
*/ */
public function Validate(): void{ public function Validate(): void{
$error = new Exceptions\ValidationException(); $error = new Exceptions\InvalidLocSubjectException();
if(isset($this->Name)){ if(isset($this->Name)){
$this->Name = trim($this->Name); $this->Name = trim($this->Name);
@ -34,7 +34,7 @@ class LocSubject{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidLocSubjectException
*/ */
public function Create(): void{ public function Create(): void{
$this->Validate(); $this->Validate();
@ -47,7 +47,7 @@ class LocSubject{
} }
/** /**
* @throws Exceptions\ValidationException * @throws Exceptions\InvalidLocSubjectException
*/ */
public function GetByNameOrCreate(string $name): LocSubject{ public function GetByNameOrCreate(string $name): LocSubject{
$result = Db::Query(' $result = Db::Query('

221
lib/Project.php Normal file
View file

@ -0,0 +1,221 @@
<?
use function Safe\preg_match;
use Safe\DateTimeImmutable;
/**
* @property Ebook $Ebook
* @property User $ManagerUser
* @property User $ReviewerUser
* @property string $Url
*/
class Project{
use Traits\Accessor;
use Traits\PropertyFromHttp;
public int $ProjectId;
public int $EbookId;
public Enums\ProjectStatusType $Status = Enums\ProjectStatusType::InProgress;
public string $ProducerName;
public ?string $ProducerEmail = null;
public ?string $DiscussionUrl = null;
public string $VcsUrl;
public DateTimeImmutable $Created;
public DateTimeImmutable $Updated;
public DateTimeImmutable $Started;
public ?DateTimeImmutable $Ended = null;
public int $ManagerUserId;
public int $ReviewerUserId;
protected Ebook $_Ebook;
protected User $_ManagerUser;
protected User $_ReviewerUser;
protected string $_Url;
protected function GetUrl(): string{
if(!isset($this->_Url)){
$this->_Url = '/projects/' . $this->ProjectId;
}
return $this->_Url;
}
/**
* @throws Exceptions\InvalidProjectException If the `Project` is invalid.
*/
public function Validate(): void{
$error = new Exceptions\InvalidProjectException();
if(!isset($this->EbookId)){
$error->Add(new Exceptions\EbookRequiredException());
}
$this->ProducerEmail = trim($this->ProducerEmail ?? '');
if($this->ProducerEmail == ''){
$this->ProducerEmail = null;
}
// If we have an email address, try to see if we have a matching `User` in the database that we can pull the name from.
if($this->ProducerEmail !== null){
try{
$user = User::GetByEmail($this->ProducerEmail);
if($user->Name !== null){
$this->ProducerName = $user->Name;
}
}
catch(Exceptions\UserNotFoundException){
// Pass.
}
}
$this->ProducerName = trim($this->ProducerName ?? '');
if($this->ProducerName == ''){
$error->Add(new Exceptions\ProducerNameRequiredException());
}
$this->DiscussionUrl = trim($this->DiscussionUrl ?? '');
if($this->DiscussionUrl == ''){
$this->DiscussionUrl = null;
}
$this->VcsUrl = rtrim(trim($this->VcsUrl ?? ''), '/');
if($this->VcsUrl == ''){
$error->Add(new Exceptions\VcsUrlRequiredException());
}
elseif(!preg_match('|^https://github.com/[^/]+/[^/]+|ius', $this->VcsUrl)){
$error->Add(new Exceptions\InvalidVcsUrlException());
}
if(!isset($this->ManagerUserId)){
$error->Add(new Exceptions\ManagerRequiredException());
}
else{
try{
$this->_ManagerUser = User::Get($this->ManagerUserId);
}
catch(Exceptions\UserNotFoundException){
$error->Add(new Exceptions\UserNotFoundException('Manager user not found.'));
}
}
if(!isset($this->ReviewerUserId)){
$error->Add(new Exceptions\ManagerRequiredException());
}
else{
try{
$this->_ReviewerUser = User::Get($this->ReviewerUserId);
}
catch(Exceptions\UserNotFoundException){
$error->Add(new Exceptions\UserNotFoundException('Reviewer user not found.'));
}
}
if(!isset($this->Started)){
$error->Add(new Exceptions\StartedTimestampRequiredException());
}
if($error->HasExceptions){
throw $error;
}
}
/**
* @throws Exceptions\InvalidProjectException If the `Project` is invalid.
* @throws Exceptions\EbookIsNotAPlaceholderException If the `Project`'s `Ebook` is not a placeholder.
* @throws Exceptions\ProjectExistsException If the `Project`'s `Ebook` already has an active `Project`.
*/
public function Create(): void{
$this->Validate();
// Is this ebook already released?
if(!$this->Ebook->IsPlaceholder()){
throw new Exceptions\EbookIsNotAPlaceholderException();
}
// Does this `Ebook` already has an active `Project`?
if($this->Ebook->ProjectInProgress !== null){
throw new Exceptions\ProjectExistsException();
}
Db::Query('
INSERT into Projects
(
EbookId,
Status,
ProducerName,
ProducerEmail,
DiscussionUrl,
VcsUrl,
Created,
Updated,
Started,
Ended,
ManagerUserId,
ReviewerUserId
)
values
(
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?
)
', [$this->EbookId, $this->Status, $this->ProducerName, $this->ProducerEmail, $this->DiscussionUrl, $this->VcsUrl, NOW, NOW, $this->Started, $this->Ended, $this->ManagerUserId, $this->ReviewerUserId]);
$this->ProjectId = Db::GetLastInsertedId();
}
/**
* @throws Exceptions\InvalidProjectException If the `Project` is invalid.
*/
public function Save(): void{
$this->Validate();
Db::Query('
UPDATE
Projects
set
Status = ?,
ProducerName = ?,
ProducerEmail = ?,
DiscussionUrl = ?,
VcsUrl = ?,
Started = ?,
Ended = ?,
ManagerUserId = ?,
ReviewerUserId = ?
where
ProjectId = ?
', [$this->Status, $this->ProducerName, $this->ProducerEmail, $this->DiscussionUrl, $this->VcsUrl, $this->Started, $this->Ended, $this->ManagerUserId, $this->ReviewerUserId, $this->ProjectId]);
if($this->Status == Enums\ProjectStatusType::Abandoned){
Db::Query('
UPDATE
EbookPlaceholders
set
IsInProgress = false
where
EbookId = ?
', [$this->EbookId]);
}
}
public function FillFromHttpPost(): void{
$this->PropertyFromHttp('ProducerName');
$this->PropertyFromHttp('ProducerEmail');
$this->PropertyFromHttp('DiscussionUrl');
$this->PropertyFromHttp('Status');
$this->PropertyFromHttp('VcsUrl');
$this->PropertyFromHttp('Started');
$this->PropertyFromHttp('Ended');
$this->PropertyFromHttp('ManagerUserId');
$this->PropertyFromHttp('ReviewerUserId');
}
}

View file

@ -345,6 +345,34 @@ class User{
', [$uuid], User::class)[0] ?? throw new Exceptions\UserNotFoundException(); ', [$uuid], User::class)[0] ?? throw new Exceptions\UserNotFoundException();
} }
/**
* @return array<User>
*/
public static function GetAllByCanManageProjects(): array{
return Db::Query('
SELECT u.*
from Users u
inner join Benefits b
using (UserId)
where b.CanManageProjects = true
order by Name asc
', [], User::class);
}
/**
* @return array<User>
*/
public static function GetAllByCanReviewProjects(): array{
return Db::Query('
SELECT u.*
from Users u
inner join Benefits b
using (UserId)
where b.CanReviewProjects = true
order by Name asc
', [], User::class);
}
/** /**
* Get a `User` if they are considered "registered". * Get a `User` if they are considered "registered".
* *

View file

@ -39,6 +39,18 @@ if($verbose){
try{ try{
$ebook->CreateOrUpdate(); $ebook->CreateOrUpdate();
// If there was an `EbookPlaceholder` for this ebook, delete it.
if($ebook->EbookPlaceholder !== null){
$ebook->EbookPlaceholder->Delete();
}
// If there was a `Project` for this ebook, mark it as completed.
if($ebook->ProjectInProgress !== null){
$ebook->ProjectInProgress->Status = Enums\ProjectStatusType::Completed;
$ebook->ProjectInProgress->Ended = NOW;
$ebook->ProjectInProgress->Save();
}
} }
catch(Exceptions\ValidationException $validationException){ catch(Exceptions\ValidationException $validationException){
$exceptions = $validationException->Exceptions; $exceptions = $validationException->Exceptions;

View file

@ -0,0 +1,34 @@
<?
/**
* @var Ebook $ebook
*/
?>
<h2>Metadata</h2>
<table class="admin-table">
<tbody>
<tr>
<td>Ebook ID:</td>
<td><?= $ebook->EbookId ?></td>
</tr>
<tr>
<td>Identifier:</td>
<td><?= Formatter::EscapeHtml($ebook->Identifier) ?></td>
</tr>
<? if(sizeof($ebook->Projects) > 0){ ?>
<tr>
<td>Projects:</td>
<td>
<ul>
<? foreach($ebook->Projects as $project){ ?>
<li>
<p>
<?= $project->Started->format(Enums\DateTimeFormat::FullDateTime->value) ?> — <?= $project->Status->GetDisplayName() ?> — <? if($project->ProducerEmail !== null){ ?><a href="mailto:<?= Formatter::EscapeHtml($project->ProducerEmail) ?>"><?= Formatter::EscapeHtml($project->ProducerName) ?></a><? }else{ ?><?= Formatter::EscapeHtml($project->ProducerName) ?><? } ?> — <a href="<?= $project->Url ?>">Link</a>
</p>
</li>
<? } ?>
</ul>
</td>
</tr>
<? } ?>
</tbody>
</table>

View file

@ -1,9 +1,5 @@
<? <?
/**
* @var ?Ebook $ebook
*/
$ebook = $ebook ?? new Ebook(); $ebook = $ebook ?? new Ebook();
?> ?>
<fieldset> <fieldset>
<legend>Contributors</legend> <legend>Contributors</legend>
@ -84,7 +80,7 @@ $ebook = $ebook ?? new Ebook();
name="ebook-placeholder-year-published" name="ebook-placeholder-year-published"
inputmode="numeric" inputmode="numeric"
pattern="[0-9]{1,4}" pattern="[0-9]{1,4}"
value="<?= Formatter::EscapeHtml((string)($ebook?->EbookPlaceholder?->YearPublished)) ?>" value="<?= Formatter::EscapeHtml((string)($ebook->EbookPlaceholder?->YearPublished)) ?>"
/> />
</label> </label>
</fieldset> </fieldset>
@ -165,16 +161,21 @@ $ebook = $ebook ?? new Ebook();
</fieldset> </fieldset>
</details> </details>
<fieldset> <fieldset>
<legend>Project</legend>
<label> <label class="controls-following-fieldset">
<span>In progress?</span> <span>In progress?</span>
<input type="hidden" name="ebook-placeholder-is-in-progress" value="false" /> <input type="hidden" name="ebook-placeholder-is-in-progress" value="false" />
<input <input
type="checkbox" type="checkbox"
name="ebook-placeholder-is-in-progress" name="ebook-placeholder-is-in-progress"
<? if($ebook?->EbookPlaceholder?->IsInProgress){ ?>checked="checked"<? } ?> <? if($ebook->EbookPlaceholder?->IsInProgress){ ?>checked="checked"<? } ?>
/> />
</label> </label>
<fieldset class="project-form">
<?= Template::ProjectForm(['project' => $ebook->ProjectInProgress]) ?>
</fieldset>
</fieldset>
<fieldset>
<legend>Wanted list</legend> <legend>Wanted list</legend>
<label class="controls-following-fieldset"> <label class="controls-following-fieldset">
<span>On the wanted list?</span> <span>On the wanted list?</span>
@ -182,7 +183,7 @@ $ebook = $ebook ?? new Ebook();
<input <input
type="checkbox" type="checkbox"
name="ebook-placeholder-is-wanted" name="ebook-placeholder-is-wanted"
<? if($ebook?->EbookPlaceholder?->IsWanted){ ?>checked="checked"<? } ?> <? if($ebook->EbookPlaceholder?->IsWanted){ ?>checked="checked"<? } ?>
/> />
</label> </label>
<fieldset> <fieldset>
@ -192,7 +193,7 @@ $ebook = $ebook ?? new Ebook();
<input <input
type="checkbox" type="checkbox"
name="ebook-placeholder-is-patron" name="ebook-placeholder-is-patron"
<? if($ebook?->EbookPlaceholder?->IsPatron){ ?>checked="checked"<? } ?> <? if($ebook->EbookPlaceholder?->IsPatron){ ?>checked="checked"<? } ?>
/> />
</label> </label>
<label class="icon meter"> <label class="icon meter">
@ -200,9 +201,9 @@ $ebook = $ebook ?? new Ebook();
<span> <span>
<select name="ebook-placeholder-difficulty"> <select name="ebook-placeholder-difficulty">
<option value=""></option> <option value=""></option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Beginner->value ?>"<? if($ebook?->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Beginner){ ?> selected="selected"<? } ?>>Beginner</option> <option value="<?= Enums\EbookPlaceholderDifficulty::Beginner->value ?>"<? if($ebook->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Beginner){ ?> selected="selected"<? } ?>>Beginner</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Intermediate->value ?>"<? if($ebook?->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Intermediate){ ?> selected="selected"<? } ?>>Intermediate</option> <option value="<?= Enums\EbookPlaceholderDifficulty::Intermediate->value ?>"<? if($ebook->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Intermediate){ ?> selected="selected"<? } ?>>Intermediate</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Advanced->value ?>"<? if($ebook?->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Advanced){ ?> selected="selected"<? } ?>>Advanced</option> <option value="<?= Enums\EbookPlaceholderDifficulty::Advanced->value ?>"<? if($ebook->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Advanced){ ?> selected="selected"<? } ?>>Advanced</option>
</select> </select>
</span> </span>
</label> </label>
@ -211,13 +212,13 @@ $ebook = $ebook ?? new Ebook();
<input <input
type="url" type="url"
name="ebook-placeholder-transcription-url" name="ebook-placeholder-transcription-url"
value="<?= Formatter::EscapeHtml($ebook?->EbookPlaceholder?->TranscriptionUrl) ?>" value="<?= Formatter::EscapeHtml($ebook->EbookPlaceholder?->TranscriptionUrl) ?>"
/> />
</label> </label>
<label> <label>
<span>Notes</span> <span>Notes</span>
<span>Markdown accepted.</span> <span>Markdown accepted.</span>
<textarea maxlength="1024" name="ebook-placeholder-notes"><?= Formatter::EscapeHtml($ebook?->EbookPlaceholder?->Notes) ?></textarea> <textarea maxlength="1024" name="ebook-placeholder-notes"><?= Formatter::EscapeHtml($ebook->EbookPlaceholder?->Notes) ?></textarea>
</label> </label>
</fieldset> </fieldset>
</fieldset> </fieldset>

78
templates/ProjectForm.php Normal file
View file

@ -0,0 +1,78 @@
<?
$project = $project ?? new Project();
$managers = User::GetAllByCanManageProjects();
$reviewers = User::GetAllByCanReviewProjects();
?>
<label class="icon user">
<span>Producer name</span>
<input
type="text"
name="project-producer-name"
required="required"
value="<?= Formatter::EscapeHtml($project->ProducerName ?? '') ?>"
/>
</label>
<label>
<span>Producer Email</span>
<input
type="email"
name="project-producer-email"
value="<?= Formatter::EscapeHtml($project->ProducerEmail) ?>"
/>
</label>
<label class="icon user">
<span>Manager</span>
<span>
<select name="project-manager-user-id">
<? foreach($managers as $manager){ ?>
<option value="<?= $manager->UserId ?>"<? if(isset($project->ManagerUserId) && $project->ManagerUserId == $manager->UserId){ ?> selected="selected"<? } ?>><?= Formatter::EscapeHtml($manager->Name) ?></option>
<? } ?>
</select>
</span>
</label>
<label class="icon user">
<span>Reviewer</span>
<span>
<select name="project-reviewer-user-id">
<? foreach($reviewers as $reviewer){ ?>
<option value="<?= $reviewer->UserId ?>"<? if(isset($project->ReviewerUserId) && $project->ReviewerUserId == $reviewer->UserId){ ?> selected="selected"<? } ?>><?= Formatter::EscapeHtml($reviewer->Name) ?></option>
<? } ?>
</select>
</span>
</label>
<label class="icon meter">
<span>Status</span>
<span>
<select name="project-status">
<option value="<?= Enums\ProjectStatusType::InProgress->value ?>"<? if($project->Status == Enums\ProjectStatusType::InProgress){?> selected="selected"<? } ?>>In progress</option>
<option value="<?= Enums\ProjectStatusType::Stalled->value ?>"<? if($project->Status == Enums\ProjectStatusType::Stalled){?> selected="selected"<? } ?>>Stalled</option>
<option value="<?= Enums\ProjectStatusType::Completed->value ?>"<? if($project->Status == Enums\ProjectStatusType::Completed){?> selected="selected"<? } ?>>Completed</option>
<option value="<?= Enums\ProjectStatusType::Abandoned->value ?>"<? if($project->Status == Enums\ProjectStatusType::Abandoned){?> selected="selected"<? } ?>>Abandoned</option>
</select>
</span>
</label>
<label>
<span>VCS URL</span>
<input
type="url"
name="project-vcs-url"
required="required"
placeholder="https://github.com/"
pattern="^https:\/\/github\.com\/[^\/]+/[^\/]+/?$"
value="<?= Formatter::EscapeHtml($project->VcsUrl ?? '') ?>"
/>
</label>
<label>
<span>Discussion URL</span>
<input
type="url"
name="project-discussion-url"
value="<?= Formatter::EscapeHtml($project->DiscussionUrl) ?>"
/>
</label>

View file

@ -2516,6 +2516,14 @@ fieldset{
border: none; border: none;
} }
label.controls-following-fieldset + fieldset{
display: none;
}
label.controls-following-fieldset:has(input[type="checkbox"]:checked) + fieldset{
display: block;
}
label.controls-following-fieldset + fieldset, label.controls-following-fieldset + fieldset,
details summary ~ *{ details summary ~ *{
margin-left: 1rem; margin-left: 1rem;

View file

@ -73,12 +73,12 @@ form div.footer{
} }
/* Hide the next fieldset unless the ebook-placeholder-is-wanted checkbox is checked. */ /* 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{ form.create-update-ebook-placeholder label.controls-following-fieldset + fieldset{
display: none; display: none;
grid-column: 1 / span 2; grid-column: 1 / span 2;
} }
form.create-update-ebook-placeholder fieldset:has(input[name="ebook-placeholder-is-wanted"]:checked) fieldset{ form.create-update-ebook-placeholder label.controls-following-fieldset:has(input[type="checkbox"]:checked) + fieldset{
display: grid; display: grid;
} }

13
www/css/project.css Normal file
View file

@ -0,0 +1,13 @@
.project-form{
display: grid;
grid-template-columns: 1fr 1fr;
}
.project-form label:has(input[type="url"]){
grid-column: 1 / span 2
}
.project-form label:has(input[name="project-manager-user-id"]){
grid-column-start: 1;
}

View file

@ -76,7 +76,7 @@ catch(Exceptions\EbookNotFoundException){
<? } ?> <? } ?>
</aside> </aside>
<section class="placeholder-details"> <section class="placeholder-details" id="details">
<? if($ebook->EbookPlaceholder->IsPublicDomain){ ?> <? if($ebook->EbookPlaceholder->IsPublicDomain){ ?>
<? if($ebook->EbookPlaceholder->IsInProgress){ ?> <? if($ebook->EbookPlaceholder->IsInProgress){ ?>
<p>We dont have this ebook in our catalog yet, but someone is working on it now! We hope to have it available for you to read very soon.</p> <p>We dont have this ebook in our catalog yet, but someone is working on it now! We hope to have it available for you to read very soon.</p>
@ -87,7 +87,7 @@ catch(Exceptions\EbookNotFoundException){
<p><a href="/donate#sponsor-an-ebook">Sponsor this ebook</a> and well get working on it immediately, so that you and everyone can read it for free forever. You can also choose to have your name inscribed in the ebooks colophon.</p> <p><a href="/donate#sponsor-an-ebook">Sponsor this ebook</a> and well get working on it immediately, so that you and everyone can read it for free forever. You can also choose to have your name inscribed in the ebooks colophon.</p>
</li> </li>
<li> <li>
<? if($ebook->EbookPlaceholder->Difficulty == \Enums\EbookPlaceholderDifficulty::Beginner){ ?> <? if($ebook->EbookPlaceholder->Difficulty == Enums\EbookPlaceholderDifficulty::Beginner){ ?>
<p><a href="/contribute#technical-contributors">Produce this ebook yourself</a> and your work will allow others to read it for free forever. <em>This book is a good choice to start with if youve never created an ebook for us before</em>—well help you through the process!</p> <p><a href="/contribute#technical-contributors">Produce this ebook yourself</a> and your work will allow others to read it for free forever. <em>This book is a good choice to start with if youve never created an ebook for us before</em>—well help you through the process!</p>
<? }else{ ?> <? }else{ ?>
<p>If youve created an ebook for us before, you can <a href="/contribute#technical-contributors">produce this ebook yourself</a> so that others can read it for free. Your name will be inscribed in the colophon as the ebooks producer.</p> <p>If youve created an ebook for us before, you can <a href="/contribute#technical-contributors">produce this ebook yourself</a> so that others can read it for free. Your name will be inscribed in the colophon as the ebooks producer.</p>
@ -105,19 +105,7 @@ catch(Exceptions\EbookNotFoundException){
<? if(Session::$User?->Benefits->CanEditEbooks){ ?> <? if(Session::$User?->Benefits->CanEditEbooks){ ?>
<section id="metadata"> <section id="metadata">
<h2>Metadata</h2> <?= Template::EbookMetadata(['ebook' => $ebook]) ?>
<table class="admin-table">
<tbody>
<tr>
<td>Ebook ID:</td>
<td><?= $ebook->EbookId ?></td>
</tr>
<tr>
<td>Identifier:</td>
<td><?= Formatter::EscapeHtml($ebook->Identifier) ?></td>
</tr>
</tbody>
</table>
</section> </section>
<? } ?> <? } ?>
</article> </article>

View file

@ -26,8 +26,11 @@ try{
// If the `EbookPlaceholder` we just added is part of a collection, prefill the form with the same data to make it easier to submit series. // If the `EbookPlaceholder` we just added is part of a collection, prefill the form with the same data to make it easier to submit series.
unset($ebook->EbookId); unset($ebook->EbookId);
unset($ebook->Title); unset($ebook->Title);
unset($ebook->ProjectInProgress);
if($ebook->EbookPlaceholder !== null){ if($ebook->EbookPlaceholder !== null){
$ebook->EbookPlaceholder->YearPublished = null; $ebook->EbookPlaceholder->YearPublished = null;
$ebook->EbookPlaceholder->IsWanted = false;
$ebook->EbookPlaceholder->IsInProgress = false;
} }
foreach($ebook->CollectionMemberships as $collectionMembership){ foreach($ebook->CollectionMemberships as $collectionMembership){
$collectionMembership->SequenceNumber++; $collectionMembership->SequenceNumber++;
@ -56,7 +59,7 @@ catch(Exceptions\InvalidPermissionsException){
<?= Template::Header( <?= Template::Header(
[ [
'title' => 'Create an Ebook Placeholder', 'title' => 'Create an Ebook Placeholder',
'css' => ['/css/ebook-placeholder.css'], 'css' => ['/css/ebook-placeholder.css', '/css/project.css'],
'highlight' => '', 'highlight' => '',
'description' => 'Create a placeholder for an ebook not yet in the collection.' 'description' => 'Create a placeholder for an ebook not yet in the collection.'
] ]

View file

@ -82,6 +82,16 @@ try{
// Pass and create the placeholder. There is no existing ebook with this identifier. // Pass and create the placeholder. There is no existing ebook with this identifier.
} }
// Do we have a `Project` to create at the same time?
$project = null;
if($ebookPlaceholder->IsInProgress){
$project = new Project();
$project->FillFromHttpPost();
$project->Started = NOW;
$project->EbookId = 0; // Dummy value to pass validation, we'll set it to the real value before creating the `Project`.
$project->Validate();
}
// 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. // 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->Tags = [];
$ebook->LocSubjects = []; $ebook->LocSubjects = [];
@ -89,6 +99,13 @@ try{
$ebook->Contributors = []; $ebook->Contributors = [];
$ebook->Create(); $ebook->Create();
if($ebookPlaceholder->IsInProgress && $project !== null){
$project->EbookId = $ebook->EbookId;
$project->Ebook = $ebook;
$project->Create();
$ebook->ProjectInProgress = $project;
}
$_SESSION['ebook'] = $ebook; $_SESSION['ebook'] = $ebook;
$_SESSION['is-ebook-placeholder-created'] = true; $_SESSION['is-ebook-placeholder-created'] = true;

View file

@ -399,19 +399,7 @@ catch(Exceptions\EbookNotFoundException){
<? if(Session::$User?->Benefits->CanEditEbooks){ ?> <? if(Session::$User?->Benefits->CanEditEbooks){ ?>
<section id="metadata"> <section id="metadata">
<h2>Metadata</h2> <?= Template::EbookMetadata(['ebook' => $ebook]) ?>
<table class="admin-table">
<tbody>
<tr>
<td>Ebook ID:</td>
<td><?= $ebook->EbookId ?></td>
</tr>
<tr>
<td>Identifier:</td>
<td><?= Formatter::EscapeHtml($ebook->Identifier) ?></td>
</tr>
</tbody>
</table>
</section> </section>
<? } ?> <? } ?>