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>
This commit is contained in:
parent
092ee0e918
commit
0a3e52d603
112 changed files with 8464 additions and 1649 deletions
472
app/Console/Commands/MigrateLegacyMedia.php
Normal file
472
app/Console/Commands/MigrateLegacyMedia.php
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue