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>
472 lines
17 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|