presseportale/app/Console/Commands/MigrateLegacyMedia.php
Kevin Adametz 0a3e52d603 19-05-2026 Rebrand Pressekonto, Hub-Flux UI und Legacy-Media-Migration
Umbenennung presseportale → pressekonto in Domains, Themes und Dokumentation.
Design-Tokens, Portal-Shell, Customer-Dashboard, Auth- und Admin-PM-Views.
Artisan-Befehl migrate:legacy-media mit Tests und Hub-Flux-Entwicklungsdocs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 16:36:13 +00:00

472 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MigrateLegacyMedia extends Command
{
protected $signature = 'legacy:migrate-media
{--portal=all : Portal (presseecho|businessportal24|all)}
{--type=all : Medientyp (company-logos|press-release-images|all)}
{--base-path=dev/migration : Lokaler Ordner mit den Legacy-Dateien}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Bereits migrierte Dateien erneut überschreiben}
{--limit=0 : Maximal N Legacy-Datensätze pro Typ und Portal verarbeiten (0 = alle)}';
protected $description = 'Migriert Legacy-Firmenlogos und Pressemitteilungsbilder anhand der Legacy-DB in den finalen Storage.';
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
/**
* @var array<string, list<string>>
*/
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<string>
*/
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<string>
*/
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<string, string>
*/
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<int, string>
*/
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);
}
}
}