'mysql_presseecho', 'businessportal24' => 'mysql_businessportal', ]; /** * @var array> */ private const SOURCE_DIRECTORIES = [ 'company-logos' => [ 'uploads/company', 'thumbnails/company', ], 'press-release-images' => [ 'uploads/pressreleaseimage', 'uploads/pressreleaseimage_', 'uploads/_pressreleaseimage', 'thumbnails/pressreleaseimage', ], ]; public function __construct(private readonly ImageService $imageService) { parent::__construct(); } public function handle(): int { $portals = $this->selectedPortals(); $types = $this->selectedTypes(); if ($portals === []) { $this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.'); return self::FAILURE; } if ($types === []) { $this->error('Ungültiger Medientyp. Erlaubt: company-logos, press-release-images, all.'); return self::FAILURE; } $configuredBasePath = (string) $this->option('base-path'); $basePath = Str::startsWith($configuredBasePath, '/') ? rtrim($configuredBasePath, '/') : base_path(trim($configuredBasePath, '/')); $dryRun = (bool) $this->option('dry-run'); $force = (bool) $this->option('force'); $limit = max(0, (int) $this->option('limit')); $totals = $this->emptyStats(); foreach ($portals as $portal) { foreach ($types as $type) { $stats = match ($type) { 'company-logos' => $this->migrateCompanyLogos($portal, $basePath, $dryRun, $force, $limit), 'press-release-images' => $this->migratePressReleaseImages($portal, $basePath, $dryRun, $force, $limit), }; foreach ($totals as $key => $value) { $totals[$key] = $value + $stats[$key]; } $this->line(sprintf( '%s/%s: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d', $portal, $type, $stats['legacy_rows'], $stats['migrated'], $stats['thumbnail_fallback'], $stats['updated'], $stats['already_synced'], $stats['missing_target'], $stats['missing_file'], )); } } $this->newLine(); $this->info(sprintf( 'Gesamt: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d%s', $totals['legacy_rows'], $totals['migrated'], $totals['thumbnail_fallback'], $totals['updated'], $totals['already_synced'], $totals['missing_target'], $totals['missing_file'], $dryRun ? ' (Dry-Run)' : '', )); return ($totals['missing_target'] + $totals['missing_file']) > 0 ? self::FAILURE : self::SUCCESS; } /** * @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int} */ private function migrateCompanyLogos(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array { $stats = $this->emptyStats(); $sourceIndex = $this->sourceIndex($basePath, $portal, 'company-logos'); $processed = 0; DB::connection(self::PORTAL_CONNECTIONS[$portal]) ->table('company') ->whereNotNull('logo') ->where('logo', '!=', '') ->orderBy('id') ->chunk(500, function ($rows) use ($portal, $sourceIndex, $dryRun, $force, $limit, &$processed, &$stats): bool { foreach ($rows as $row) { if ($limit > 0 && $processed >= $limit) { return false; } $processed++; $stats['legacy_rows']++; $company = Company::withoutGlobalScopes() ->where('legacy_portal', $portal) ->where('legacy_id', $row->id) ->first(['id', 'logo_path', 'logo_variants']); if (! $company) { $stats['missing_target']++; $this->warn("Ziel fehlt: {$portal}/company/{$row->id}"); continue; } $sourceFilename = $this->legacyFilename((string) $row->logo); $sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null; $destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}"; if ($this->isAlreadySynced($company->logo_path, $destinationPath, $force)) { $stats['already_synced']++; if (! $dryRun && (! is_array($company->logo_variants) || $company->logo_variants === [])) { $variants = $this->imageService->generateMissingCompanyLogoVariants($destinationPath); if ($variants !== []) { $company->forceFill(['logo_variants' => $variants])->save(); $stats['updated']++; } } continue; } if (! $sourcePath) { $stats['missing_file']++; $this->warn("Datei fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})"); continue; } if (! $dryRun) { $this->copyToPublicStorage($sourcePath, $destinationPath, $force); $company->forceFill([ 'logo_path' => $destinationPath, 'logo_variants' => $this->imageService->generateMissingCompanyLogoVariants($destinationPath), ])->save(); } $stats['migrated']++; $stats['updated']++; } return true; }); return $stats; } /** * @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int} */ private function migratePressReleaseImages(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array { $stats = $this->emptyStats(); $sourceIndex = $this->sourceIndex($basePath, $portal, 'press-release-images'); $thumbnailIndex = $this->thumbnailIndex($basePath, $portal); $processed = 0; DB::connection(self::PORTAL_CONNECTIONS[$portal]) ->table('press_release_image') ->orderBy('id') ->chunk(500, function ($rows) use ($portal, $sourceIndex, $thumbnailIndex, $dryRun, $force, $limit, &$processed, &$stats): bool { foreach ($rows as $row) { if ($limit > 0 && $processed >= $limit) { return false; } if (blank($row->image)) { continue; } $processed++; $stats['legacy_rows']++; $pressRelease = PressRelease::withoutGlobalScopes() ->where('legacy_portal', $portal) ->where('legacy_id', $row->press_release_id) ->first(['id']); if (! $pressRelease) { $stats['missing_target']++; $this->warn("Ziel fehlt: {$portal}/press_release/{$row->press_release_id}"); continue; } $sourceFilename = $this->legacyFilename((string) $row->image); $sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null; $usedThumbnailFallback = false; if (! $sourcePath) { $sourcePath = $thumbnailIndex[(int) $row->id] ?? null; $usedThumbnailFallback = $sourcePath !== null; } $destinationFilename = $usedThumbnailFallback ? basename($sourcePath) : $sourceFilename; $destinationPath = "press-releases/{$pressRelease->id}/images/{$destinationFilename}"; $image = PressReleaseImage::withTrashed() ->where('legacy_portal', $portal) ->where('legacy_id', $row->id) ->first(); if ($image && $this->isAlreadySynced($image->path, $destinationPath, $force)) { $stats['already_synced']++; if (! $dryRun && (! is_array($image->variants) || $image->variants === [])) { $variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath); if ($variants !== []) { $image->forceFill(['variants' => $variants])->save(); $stats['updated']++; } } continue; } if (! $sourcePath) { $stats['missing_file']++; $this->warn("Datei fehlt: {$portal}/press_release_image/{$sourceFilename} (Image #{$row->id})"); continue; } if ($usedThumbnailFallback) { $stats['thumbnail_fallback']++; } $variants = []; $size = [null, null]; $mime = null; if (! $dryRun) { $this->copyToPublicStorage($sourcePath, $destinationPath, $force); $variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath); $size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null]; $mime = File::mimeType($sourcePath) ?: null; $image = $image ?: new PressReleaseImage; $image->forceFill([ 'press_release_id' => $pressRelease->id, 'disk' => 'public', 'path' => $destinationPath, 'variants' => $variants, 'title' => $row->title ?: null, 'description' => $row->description ?: null, 'copyright' => $row->copyright ?: null, 'is_preview' => (bool) $row->is_preview_image, 'sort_order' => 0, 'width' => is_int($size[0] ?? null) ? $size[0] : null, 'height' => is_int($size[1] ?? null) ? $size[1] : null, 'mime' => $mime, 'legacy_portal' => $portal, 'legacy_id' => $row->id, ]); if ($image->trashed()) { $image->restore(); } $image->save(); } $stats['migrated']++; $stats['updated']++; } return true; }); return $stats; } /** * @return list */ private function selectedPortals(): array { $portal = (string) $this->option('portal'); return match ($portal) { 'all' => [Portal::Presseecho->value, Portal::Businessportal24->value], Portal::Presseecho->value, Portal::Businessportal24->value => [$portal], default => [], }; } /** * @return list */ private function selectedTypes(): array { $type = (string) $this->option('type'); return match ($type) { 'all' => ['company-logos', 'press-release-images'], 'company-logos', 'press-release-images' => [$type], default => [], }; } /** * @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int} */ private function emptyStats(): array { return [ 'legacy_rows' => 0, 'migrated' => 0, 'thumbnail_fallback' => 0, 'updated' => 0, 'already_synced' => 0, 'missing_target' => 0, 'missing_file' => 0, ]; } /** * @return array */ private function sourceIndex(string $basePath, string $portal, string $type): array { $index = []; foreach (self::SOURCE_DIRECTORIES[$type] as $relativeDirectory) { $directory = "{$basePath}/{$portal}/{$relativeDirectory}"; if (! File::isDirectory($directory)) { continue; } foreach (File::allFiles($directory) as $file) { $index[$file->getFilename()] ??= $file->getPathname(); $index[Str::lower($file->getFilename())] ??= $file->getPathname(); } } return $index; } /** * @return array */ private function thumbnailIndex(string $basePath, string $portal): array { $directory = "{$basePath}/{$portal}/thumbnails/pressreleaseimage"; if (! File::isDirectory($directory)) { return []; } $index = []; $priorities = [ 'press_image_preview' => 50, 'pressrelease_form' => 40, 'backend_list' => 30, 'press_image_list' => 20, 'press_list_image' => 10, ]; foreach (File::allFiles($directory) as $file) { if (! preg_match('/-(\d+)\.[^.]+$/', $file->getFilename(), $matches)) { continue; } $legacyImageId = (int) $matches[1]; $thumbnailType = Str::after($file->getPathname(), "{$directory}/"); $thumbnailType = Str::before($thumbnailType, DIRECTORY_SEPARATOR); $priority = $priorities[$thumbnailType] ?? 0; $current = $index[$legacyImageId] ?? null; if (! $current || $priority > ($current['priority'] ?? 0)) { $index[$legacyImageId] = [ 'path' => $file->getPathname(), 'priority' => $priority, ]; } } return collect($index) ->map(fn (array $entry): string => $entry['path']) ->all(); } private function legacyFilename(string $path): string { $urlPath = parse_url($path, PHP_URL_PATH); $filename = basename(rawurldecode((string) ($urlPath ?: $path))); return Str::of($filename)->trim()->toString(); } private function isAlreadySynced(?string $currentPath, string $destinationPath, bool $force): bool { return ! $force && $currentPath === $destinationPath && Storage::disk('public')->exists($destinationPath); } private function copyToPublicStorage(string $sourcePath, string $destinationPath, bool $force): void { if (! $force && Storage::disk('public')->exists($destinationPath)) { return; } $stream = fopen($sourcePath, 'rb'); if ($stream === false) { throw new \RuntimeException("Quelldatei kann nicht gelesen werden: {$sourcePath}"); } try { Storage::disk('public')->put($destinationPath, $stream, 'public'); } finally { fclose($stream); } } }