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 */ 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 $byId receives the files named by book ID, when $idPattern is given * * @return array 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 $ownById * @param array $ownByIsbn * @param array $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 */ 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 $files * @param list $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 $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); } }