diff --git a/README.md b/README.md index 1db18d8..0e75308 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ $hasMetadata = $ebook->hasMetadata(); // bool $book = $ebook->book(); // BookEntity $book->title(); // string +$book->titleMeta(); // TitleMeta, with `slug` and `sort` properties for `title` and `series` $book->authors(); // BookCreator[] (name: string, role: string) $book->authorFirst(); // First BookCreator (name: string, role: string) $book->description(); // string diff --git a/composer.json b/composer.json index 1273ace..24b4a84 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "kiwilan/php-ebook", "description": "PHP package to read metadata and extract covers from eBooks (.epub, .cbz, .cbr, .cb7, .cbt, .pdf).", - "version": "0.3.0", + "version": "0.3.10", "keywords": [ "php", "ebook", diff --git a/src/BookEntity.php b/src/BookEntity.php index 5b83d20..463075c 100755 --- a/src/BookEntity.php +++ b/src/BookEntity.php @@ -5,6 +5,8 @@ use DateTime; use Kiwilan\Ebook\Book\BookCreator; use Kiwilan\Ebook\Book\BookIdentifier; +use Kiwilan\Ebook\Entity\ComicMeta; +use Kiwilan\Ebook\Entity\TitleMeta; use Kiwilan\Ebook\Enums\AgeRatingEnum; use Kiwilan\Ebook\Enums\MangaEnum; @@ -12,6 +14,8 @@ class BookEntity { protected ?string $title = null; + protected ?TitleMeta $titleMeta = null; + protected ?BookCreator $authorFirst = null; /** @var BookCreator[] */ @@ -86,6 +90,14 @@ public function title(): ?string return $this->title; } + /** + * Title metadata of the book with slug, sort title, series slug, etc. + */ + public function titleMeta(): ?TitleMeta + { + return $this->titleMeta; + } + /** * First author of the book (useful if you need to display only one author). */ @@ -287,6 +299,13 @@ public function setTitle(?string $title): self return $this; } + public function setTitleMeta(Ebook $ebook): self + { + $this->titleMeta = TitleMeta::make($ebook); + + return $this; + } + public function setAuthorFirst(?BookCreator $authorFirst): self { $this->authorFirst = $authorFirst; @@ -515,190 +534,3 @@ public function __toString(): string return "{$this->title} by {$authors}"; } } - -class ComicMeta -{ - /** @var string[] */ - protected ?array $characters = null; - - /** @var string[] */ - protected ?array $teams = null; - - /** @var string[] */ - protected ?array $locations = null; - - public function __construct( - protected ?string $alternateSeries = null, - protected ?int $alternateNumber = null, - protected ?string $alternateCount = null, - protected ?int $count = null, - protected ?int $volume = null, - protected ?string $storyArc = null, - protected ?int $storyArcNumber = null, - protected ?string $seriesGroup = null, - protected ?string $imprint = null, - ) { - } - - /** - * @return string[] - */ - public function characters(): array - { - return $this->characters; - } - - /** - * @return string[] - */ - public function teams(): array - { - return $this->teams; - } - - /** - * @return string[] - */ - public function locations(): array - { - return $this->locations; - } - - public function alternateSeries(): ?string - { - return $this->alternateSeries; - } - - public function alternateNumber(): ?int - { - return $this->alternateNumber; - } - - public function alternateCount(): ?string - { - return $this->alternateCount; - } - - public function count(): ?int - { - return $this->count; - } - - public function volume(): ?int - { - return $this->volume; - } - - public function storyArc(): ?string - { - return $this->storyArc; - } - - public function storyArcNumber(): ?int - { - return $this->storyArcNumber; - } - - public function seriesGroup(): ?string - { - return $this->seriesGroup; - } - - public function imprint(): ?string - { - return $this->imprint; - } - - /** - * @param string[] $characters - */ - public function setCharacters(array $characters): self - { - $this->characters = $characters; - - return $this; - } - - /** - * @param string[] $teams - */ - public function setTeams(array $teams): self - { - $this->teams = $teams; - - return $this; - } - - /** - * @param string[] $locations - */ - public function setLocations(array $locations): self - { - $this->locations = $locations; - - return $this; - } - - public function setAlternateSeries(?string $alternateSeries): self - { - $this->alternateSeries = $alternateSeries; - - return $this; - } - - public function setAlternateNumber(?int $alternateNumber): self - { - $this->alternateNumber = $alternateNumber; - - return $this; - } - - public function setAlternateCount(?string $alternateCount): self - { - $this->alternateCount = $alternateCount; - - return $this; - } - - public function setCount(?int $count): self - { - $this->count = $count; - - return $this; - } - - public function setVolume(?int $volume): self - { - $this->volume = $volume; - - return $this; - } - - public function setStoryArc(?string $storyArc): self - { - $this->storyArc = $storyArc; - - return $this; - } - - public function setStoryArcNumber(?int $storyArcNumber): self - { - $this->storyArcNumber = $storyArcNumber; - - return $this; - } - - public function setSeriesGroup(?string $seriesGroup): self - { - $this->seriesGroup = $seriesGroup; - - return $this; - } - - public function setImprint(?string $imprint): self - { - $this->imprint = $imprint; - - return $this; - } -} diff --git a/src/Cba/CbaCbam.php b/src/Cba/CbaCbam.php index 767ad6e..7a900ba 100644 --- a/src/Cba/CbaCbam.php +++ b/src/Cba/CbaCbam.php @@ -5,7 +5,7 @@ use DateTime; use Kiwilan\Ebook\Book\BookCreator; use Kiwilan\Ebook\BookEntity; -use Kiwilan\Ebook\ComicMeta; +use Kiwilan\Ebook\Entity\ComicMeta; use Kiwilan\Ebook\Enums\AgeRatingEnum; use Kiwilan\Ebook\Enums\MangaEnum; diff --git a/src/Ebook.php b/src/Ebook.php index fc9c103..9e917b4 100755 --- a/src/Ebook.php +++ b/src/Ebook.php @@ -53,6 +53,8 @@ public static function read(string $path): self 'pdf' => $self->pdf(), }; + $self->book?->setTitleMeta($self); + return $self; } diff --git a/src/Entity/ComicMeta.php b/src/Entity/ComicMeta.php new file mode 100644 index 0000000..7c6f1c2 --- /dev/null +++ b/src/Entity/ComicMeta.php @@ -0,0 +1,190 @@ +characters; + } + + /** + * @return string[] + */ + public function teams(): array + { + return $this->teams; + } + + /** + * @return string[] + */ + public function locations(): array + { + return $this->locations; + } + + public function alternateSeries(): ?string + { + return $this->alternateSeries; + } + + public function alternateNumber(): ?int + { + return $this->alternateNumber; + } + + public function alternateCount(): ?string + { + return $this->alternateCount; + } + + public function count(): ?int + { + return $this->count; + } + + public function volume(): ?int + { + return $this->volume; + } + + public function storyArc(): ?string + { + return $this->storyArc; + } + + public function storyArcNumber(): ?int + { + return $this->storyArcNumber; + } + + public function seriesGroup(): ?string + { + return $this->seriesGroup; + } + + public function imprint(): ?string + { + return $this->imprint; + } + + /** + * @param string[] $characters + */ + public function setCharacters(array $characters): self + { + $this->characters = $characters; + + return $this; + } + + /** + * @param string[] $teams + */ + public function setTeams(array $teams): self + { + $this->teams = $teams; + + return $this; + } + + /** + * @param string[] $locations + */ + public function setLocations(array $locations): self + { + $this->locations = $locations; + + return $this; + } + + public function setAlternateSeries(?string $alternateSeries): self + { + $this->alternateSeries = $alternateSeries; + + return $this; + } + + public function setAlternateNumber(?int $alternateNumber): self + { + $this->alternateNumber = $alternateNumber; + + return $this; + } + + public function setAlternateCount(?string $alternateCount): self + { + $this->alternateCount = $alternateCount; + + return $this; + } + + public function setCount(?int $count): self + { + $this->count = $count; + + return $this; + } + + public function setVolume(?int $volume): self + { + $this->volume = $volume; + + return $this; + } + + public function setStoryArc(?string $storyArc): self + { + $this->storyArc = $storyArc; + + return $this; + } + + public function setStoryArcNumber(?int $storyArcNumber): self + { + $this->storyArcNumber = $storyArcNumber; + + return $this; + } + + public function setSeriesGroup(?string $seriesGroup): self + { + $this->seriesGroup = $seriesGroup; + + return $this; + } + + public function setImprint(?string $imprint): self + { + $this->imprint = $imprint; + + return $this; + } +} diff --git a/src/Entity/TitleMeta.php b/src/Entity/TitleMeta.php new file mode 100644 index 0000000..6ab1949 --- /dev/null +++ b/src/Entity/TitleMeta.php @@ -0,0 +1,243 @@ + [ + 'the ', + 'a ', + ], + 'fr' => [ + 'les ', + "l'", + 'le ', + 'la ', + "d'un", + "d'", + 'une ', + 'au ', + ], + ], + ): self { + $self = new self(); + + $self->determiners = $determiners; + $self->setTitleMeta($ebook); + + return $self; + } + + private function setTitleMeta(Ebook $ebook): static + { + $book = $ebook->book(); + + if (! $book->title()) { + return $this; + } + + $this->slug = $this->setSlug($book->title()); + $this->slugSort = $this->generateSortTitle($book->title(), $book->language()); + $this->slugLang = $this->generateSlug($book->title(), $ebook->extension(), $book->language()); + + if (! $book->series()) { + return $this; + } + + $this->serieSlug = $this->setSlug($book->series()); + $this->serieSlugSort = $this->generateSortTitle($book->series(), $book->language()); + $this->serieSlugLang = $this->generateSlug($book->series(), $ebook->extension(), $book->language()); + + $this->slugSortWithSerie = $this->generateSortSerie($book->title(), $book->series(), $book->volume(), $book->language()); + + return $this; + } + + /** + * Get slug of book title, like `le-clan-de-lours-des-cavernes`. + */ + public function slug(): ?string + { + return $this->slug; + } + + /** + * Get slug of book title without determiners, like `clan-de-lours-des-cavernes`. + */ + public function slugSort(): ?string + { + return $this->slugSort; + } + + /** + * Get slug of book title with language, like `le-clan-de-lours-des-cavernes-epub-fr`. + */ + public function slugLang(): ?string + { + return $this->slugLang; + } + + /** + * Get slug of serie title, like `les-enfants-de-la-terre`. + */ + public function serieSlug(): ?string + { + return $this->serieSlug; + } + + /** + * Get slug of serie title without determiners, like `enfants-de-la-terre`. + */ + public function serieSlugSort(): ?string + { + return $this->serieSlugSort; + } + + /** + * Get slug of serie title with language, like `les-enfants-de-la-terre-epub-fr`. + */ + public function serieSlugLang(): ?string + { + return $this->serieSlugLang; + } + + /** + * Get slug of book title with serie title, like `enfants-de-la-terre-01_clan-de-lours-des-cavernes`. + */ + public function slugSortWithSerie(): ?string + { + return $this->slugSortWithSerie; + } + + /** + * Try to get sort title. + * Example: `collier-de-la-reine` from `Le Collier de la Reine`. + */ + private function generateSortTitle(?string $title, ?string $language): ?string + { + if (! $title) { + return null; + } + + $slugSort = $title; + $articles = $this->determiners; + + $articlesLang = $articles['en']; + + if ($language && array_key_exists($language, $articles)) { + $articlesLang = $articles[$language]; + } + + foreach ($articlesLang as $key => $value) { + $slugSort = preg_replace('/^'.preg_quote($value, '/').'/i', '', $slugSort); + } + + $transliterator = Transliterator::createFromRules(':: Any-Latin; :: Latin-ASCII; :: NFD; :: [:Nonspacing Mark:] Remove; :: Lower(); :: NFC;', Transliterator::FORWARD); + $slugSort = $transliterator->transliterate($slugSort); + $slugSort = strtolower($slugSort); + + return $this->setSlug(mb_convert_encoding($slugSort, 'UTF-8')); + } + + /** + * Generate full title sort. + * Example: `miserables-01_fantine` from `Les Misérables, volume 01 : Fantine`. + */ + private function generateSortSerie(string $title, ?string $serieTitle, ?int $volume, ?string $language): string + { + $serie = null; + + if ($serieTitle) { + // @phpstan-ignore-next-line + $volume = strlen($volume) < 2 ? '0'.$volume : $volume; + $serie = $serieTitle.' '.$volume; + $serie = $this->setSlug($this->generateSortTitle($serie, $language)).'_'; + } + $title = $this->setSlug($this->generateSortTitle($title, $language)); + + return "{$serie}{$title}"; + } + + /** + * Generate `slug` with `title`, `BookTypeEnum` and `language_slug`. + */ + private function generateSlug(string $title, ?string $type, ?string $language): string + { + return $this->setSlug($title.' '.$type.' '.$language); + } + + public function toArray(): array + { + return [ + 'slug' => $this->slug, + 'slugSort' => $this->slugSort, + 'slugLang' => $this->slugLang, + + 'serieSlug' => $this->serieSlug, + 'serieSlugSort' => $this->serieSlugSort, + 'serieSlugLang' => $this->serieSlugLang, + + 'slugSortWithSerie' => $this->slugSortWithSerie, + ]; + } + + public function __toString(): string + { + return "{$this->slug} {$this->slugSort}"; + } + + /** + * Laravel export. + * Generate a URL friendly "slug" from a given string. + * + * @param array $dictionary + */ + private function setSlug(?string $title, string $separator = '-', array $dictionary = ['@' => 'at']): ?string + { + if (! $title) { + return null; + } + + // Convert all dashes/underscores into separator + $flip = $separator === '-' ? '_' : '-'; + + $title = preg_replace('!['.preg_quote($flip).']+!u', $separator, $title); + + // Replace dictionary words + foreach ($dictionary as $key => $value) { + $dictionary[$key] = $separator.$value.$separator; + } + + $title = str_replace(array_keys($dictionary), array_values($dictionary), $title); + + // Remove all characters that are not the separator, letters, numbers, or whitespace + $title = preg_replace('![^'.preg_quote($separator).'\pL\pN\s]+!u', '', strtolower($title)); + + // Replace all separator characters and whitespace by a single separator + $title = preg_replace('!['.preg_quote($separator).'\s]+!u', $separator, $title); + + return trim($title, $separator); + } +} diff --git a/tests/CbaTest.php b/tests/CbaTest.php index e5fe6be..cf5a0d2 100644 --- a/tests/CbaTest.php +++ b/tests/CbaTest.php @@ -5,7 +5,7 @@ use Kiwilan\Ebook\Cba\CbaCbam; use Kiwilan\Ebook\Cba\CbaFormat; -use Kiwilan\Ebook\ComicMeta; +use Kiwilan\Ebook\Entity\ComicMeta; use Kiwilan\Ebook\Enums\AgeRatingEnum; use Kiwilan\Ebook\Enums\MangaEnum; use Kiwilan\Ebook\XmlReader; @@ -176,3 +176,39 @@ expect($metadata->toJson())->toBeString(); expect($metadata->__toString())->toBeString(); })->with([CBZ_CBAM]); + +it('can use ComicMeta', function () { + $meta = new ComicMeta(); + + $meta->setCharacters(['character 1', 'character 2']); + $meta->setTeams(['team 1', 'team 2']); + $meta->setLocations(['location 1', 'location 2']); + $meta->setAlternateSeries('alternate series'); + $meta->setAlternateNumber(1); + $meta->setAlternateCount('alternate count'); + $meta->setCount(1); + $meta->setVolume(1); + $meta->setStoryArc('story arc'); + $meta->setStoryArcNumber(1); + $meta->setSeriesGroup('series group'); + $meta->setImprint('imprint'); + + expect($meta->characters())->toBeArray(); + expect($meta->characters())->toHaveCount(2); + expect($meta->characters()[0])->toBe('character 1'); + expect($meta->teams())->toBeArray(); + expect($meta->teams())->toHaveCount(2); + expect($meta->teams()[0])->toBe('team 1'); + expect($meta->locations())->toBeArray(); + expect($meta->locations())->toHaveCount(2); + expect($meta->locations()[0])->toBe('location 1'); + expect($meta->alternateSeries())->toBe('alternate series'); + expect($meta->alternateNumber())->toBe(1); + expect($meta->alternateCount())->toBe('alternate count'); + expect($meta->count())->toBe(1); + expect($meta->volume())->toBe(1); + expect($meta->storyArc())->toBe('story arc'); + expect($meta->storyArcNumber())->toBe(1); + expect($meta->seriesGroup())->toBe('series group'); + expect($meta->imprint())->toBe('imprint'); +}); diff --git a/tests/EntityTest.php b/tests/EntityTest.php index 8b9bdaf..43c439e 100644 --- a/tests/EntityTest.php +++ b/tests/EntityTest.php @@ -55,6 +55,10 @@ expect($item->volume())->toBe(1); expect($item->rating())->toBe(10.0); expect($item->pageCount())->toBe(4); + + expect($item->toArray())->toBeArray(); + expect($item->toJson())->toBeString(); + expect($item->__toString())->toBeString(); }); it('can use BookContributor', function (string $content, string $role) { diff --git a/tests/EpubTest.php b/tests/EpubTest.php index 9024446..8ed1954 100644 --- a/tests/EpubTest.php +++ b/tests/EpubTest.php @@ -49,3 +49,19 @@ expect(file_exists($path))->toBeTrue(); expect($path)->toBeReadableFile(); }); + +it('can get title meta', function () { + $book = Kiwilan\Ebook\Ebook::read(EPUB)->book(); + $meta = $book->titleMeta(); + + expect($meta->slug())->toBe('le-clan-de-lours-des-cavernes'); + expect($meta->slugSort())->toBe('clan-de-lours-des-cavernes'); + expect($meta->slugLang())->toBe('le-clan-de-lours-des-cavernes-epub-fr'); + expect($meta->serieSlug())->toBe('les-enfants-de-la-terre'); + expect($meta->serieSlugSort())->toBe('enfants-de-la-terre'); + expect($meta->serieSlugLang())->toBe('les-enfants-de-la-terre-epub-fr'); + expect($meta->slugSortWithSerie())->toBe('enfants-de-la-terre-01_clan-de-lours-des-cavernes'); + + expect($meta->toArray())->toBeArray(); + expect($meta->__toString())->toBeString(); +});