<?php
|
declare(strict_types=1);
|
|
namespace App\LegacyImport;
|
|
use App\Entity\AbstractBook;
|
use App\Entity\AbstractLabel;
|
use App\Entity\Book;
|
use App\Entity\OustedBook;
|
use App\Enum\LabelType;
|
use App\Service\CoverProcessingException;
|
use App\Service\CoverStorage;
|
use Doctrine\ORM\EntityManagerInterface;
|
|
class CoverImporter {
|
private const string OWN_DIR = 'own';
|
private const string ISBN_PATTERN = '/^\d{13}$/';
|
private const string ID_PATTERN = '/^\d{8}$/';
|
|
public function __construct(
|
private readonly EntityManagerInterface $entityManager,
|
private readonly CoverStorage $covers,
|
) {
|
}
|
|
/**
|
* @throws \InvalidArgumentException when the directory cannot be read
|
*/
|
public function import(string $directory, bool $dryRun = false): CoverImportReport {
|
$directory = rtrim($directory, '/');
|
|
if (!is_dir($directory) || !is_readable($directory)) {
|
throw new \InvalidArgumentException(\sprintf('"%s" is not a readable directory.', $directory));
|
}
|
|
$report = new CoverImportReport();
|
$byIsbn = $this->listFiles($directory, self::ISBN_PATTERN, null, $report);
|
$ownByIsbn = [];
|
$ownById = [];
|
|
if (is_dir($directory . '/' . self::OWN_DIR)) {
|
$ownByIsbn = $this->listFiles($directory . '/' . self::OWN_DIR, self::ISBN_PATTERN, self::ID_PATTERN, $report, $ownById);
|
}
|
|
$report->ignored = array_map(static fn(string $path): string => self::relative($directory, $path), $report->ignored);
|
$used = [];
|
$failed = [];
|
$stored = [];
|
|
$connection = $this->entityManager->getConnection();
|
$connection->beginTransaction();
|
|
try {
|
foreach ($this->books() as $book) {
|
$candidates = self::candidates($book, $ownById, $ownByIsbn, $byIsbn);
|
|
foreach ($candidates as $path) {
|
$used[$path] = true;
|
}
|
|
if ($candidates === []) {
|
continue;
|
}
|
|
if ($book->getCover() !== null) {
|
++$report->alreadyCovered;
|
continue;
|
}
|
|
// A file that is not an image gives way to the next one in order of preference.
|
foreach ($candidates as $source => $path) {
|
if (isset($failed[$path])) {
|
continue;
|
}
|
|
try {
|
$name = $this->covers->store((string)file_get_contents($path));
|
} catch (CoverProcessingException $exception) {
|
$failed[$path] = true;
|
$report->failed[] = \sprintf('%s: %s', self::relative($directory, $path), $exception->getMessage());
|
continue;
|
}
|
|
$stored[] = $name;
|
$book->setCover($name);
|
++$report->{$source};
|
break;
|
}
|
}
|
|
$this->entityManager->flush();
|
$dryRun ? $connection->rollBack() : $connection->commit();
|
} catch (\Throwable $exception) {
|
if ($connection->isTransactionActive()) {
|
$connection->rollBack();
|
}
|
$this->deleteAll($stored);
|
|
throw $exception;
|
}
|
|
if ($dryRun) {
|
$this->deleteAll($stored);
|
}
|
|
$this->entityManager->clear();
|
|
foreach ([...$byIsbn, ...$ownByIsbn, ...$ownById] as $path) {
|
if (!isset($used[$path])) {
|
$report->unmatched[] = self::relative($directory, $path);
|
}
|
}
|
sort($report->unmatched);
|
|
return $report;
|
}
|
|
/**
|
* @return iterable<AbstractBook>
|
*/
|
private function books(): iterable {
|
yield from $this->entityManager->getRepository(Book::class)->findBy([], ['id' => 'ASC']);
|
yield from $this->entityManager->getRepository(OustedBook::class)->findBy([], ['id' => 'ASC']);
|
}
|
|
/**
|
* @param array<int, string> $byId receives the files named by book ID, when $idPattern is given
|
*
|
* @return array<string, string> paths of the files named by ISBN, by ISBN
|
*/
|
private function listFiles(string $directory, string $isbnPattern, ?string $idPattern, CoverImportReport $report, array &$byId = []): array {
|
$byIsbn = [];
|
$names = scandir($directory);
|
|
if ($names === false) {
|
throw new \InvalidArgumentException(\sprintf('"%s" could not be listed.', $directory));
|
}
|
|
sort($names, \SORT_STRING);
|
|
foreach ($names as $name) {
|
$path = $directory . '/' . $name;
|
|
// Empty files are placeholders for missing covers, not images.
|
if (str_starts_with($name, '.') || is_dir($path) || filesize($path) === 0) {
|
continue;
|
}
|
|
if (preg_match($isbnPattern, $name) === 1) {
|
$byIsbn[$name] = $path;
|
} elseif ($idPattern !== null && preg_match($idPattern, $name) === 1) {
|
$byId[(int)$name] = $path;
|
} else {
|
$report->ignored[] = $path;
|
}
|
}
|
|
return $byIsbn;
|
}
|
|
/**
|
* @param array<int, string> $ownById
|
* @param array<string, string> $ownByIsbn
|
* @param array<string, string> $byIsbn
|
*
|
* @return array<'ownById'|'ownByIsbn'|'byIsbn', string> matching files in order of preference,
|
* by the report counter each adds to
|
*/
|
private static function candidates(AbstractBook $book, array $ownById, array $ownByIsbn, array $byIsbn): array {
|
$isbns = self::isbns($book);
|
|
return array_filter([
|
'ownById' => $ownById[(int)$book->getId()] ?? null,
|
'ownByIsbn' => self::firstMatch($ownByIsbn, $isbns),
|
'byIsbn' => self::firstMatch($byIsbn, $isbns),
|
], static fn(?string $path): bool => $path !== null);
|
}
|
|
/**
|
* @return list<string>
|
*/
|
private static function isbns(AbstractBook $book): array {
|
$isbns = [];
|
|
/** @var AbstractLabel $label */
|
foreach ($book->getLabels() as $label) {
|
if ($label->getType() === LabelType::ISBN) {
|
$isbns[] = $label->getLabel();
|
}
|
}
|
|
return $isbns;
|
}
|
|
/**
|
* @param array<string, string> $files
|
* @param list<string> $isbns
|
*/
|
private static function firstMatch(array $files, array $isbns): ?string {
|
foreach ($isbns as $isbn) {
|
if (isset($files[$isbn])) {
|
return $files[$isbn];
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* @param list<string> $names
|
*/
|
private function deleteAll(array $names): void {
|
foreach ($names as $name) {
|
$this->covers->delete($name);
|
}
|
}
|
|
private static function relative(string $directory, string $path): string {
|
return substr($path, \strlen($directory) + 1);
|
}
|
}
|