diff --git a/app/Console/Commands/RegeneratePressReleaseImageVariants.php b/app/Console/Commands/RegeneratePressReleaseImageVariants.php new file mode 100644 index 00000000..d77cdf76 --- /dev/null +++ b/app/Console/Commands/RegeneratePressReleaseImageVariants.php @@ -0,0 +1,101 @@ +option('only') !== 'og') { + $this->error('Derzeit wird nur --only=og unterstützt.'); + + return self::FAILURE; + } + + $query = PressReleaseImage::query() + ->with(['pressRelease' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)->with('company')]) + ->orderBy('id'); + + if ($this->option('published')) { + $query->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->where('status', 'published')); + } + + if (! $this->option('force')) { + $query->where(function ($q): void { + $q->whereNull('variants')->orWhereNull('variants->og'); + }); + } + + $total = $query->count(); + $limit = (int) $this->option('limit'); + $execute = (bool) $this->option('execute'); + + $this->info(($execute ? 'Erzeuge' : 'Trockenlauf:').' OG-Varianten für '.($limit > 0 ? min($limit, $total) : $total).' von '.$total.' Bildern.'); + + if (! $execute) { + return self::SUCCESS; + } + + $done = 0; + $skipped = 0; + + $query->chunkById(200, function ($images) use (&$done, &$skipped, $limit): bool { + foreach ($images as $image) { + if ($limit > 0 && $done >= $limit) { + return false; + } + + $caption = $image->pressRelease ? ImageCredit::for($image, $image->pressRelease->company)->line() : null; + $path = $this->imageService->regenerateOgVariant( + $image->disk, + $image->variants, + $image->path, + $image->focus_x !== null && $image->focus_y !== null ? ['x' => $image->focus_x, 'y' => $image->focus_y] : null, + (bool) $image->no_crop, + $caption, + ); + + if ($path === null) { + $skipped++; + + continue; + } + + $image->forceFill(['variants' => array_merge((array) $image->variants, ['og' => $path])])->saveQuietly(); + $done++; + } + + return true; + }); + + $this->info("OG-Varianten erzeugt: {$done}, übersprungen (keine Quelle): {$skipped}."); + + return self::SUCCESS; + } +} diff --git a/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php b/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php index 7036d09b..2c8af7d3 100644 --- a/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php +++ b/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php @@ -137,3 +137,32 @@ test('teasers carry the credit in alt and title and the hero shows a caption', f ->assertSee('alt="Teaser-Meldung · Foto: Anna Beispiel · KI-generiert"', false) ->assertSee('data-image-credit-teaser', false); }); + +test('the regenerate command builds missing og variants for the existing stock with the credit line', function () { + /** @var TestCase $this */ + $company = Company::factory()->presseecho()->create(['name' => 'Bestand GmbH', 'is_active' => true]); + $pr = PressRelease::factory()->published()->for($company)->create(['published_ever_at' => now()->subDay()]); + + $upload = UploadedFile::fake()->image('alt.jpg', 1600, 900)->size(100); + $stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local'); + $variants = $stored['variants']; + Storage::disk('local')->delete($variants['og']); + unset($variants['og']); + + $image = PressReleaseImage::factory()->for($pr)->legacy()->create(['disk' => 'local', 'path' => $stored['path'], 'variants' => $variants]); + + $this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true]) + ->expectsOutputToContain('Trockenlauf: OG-Varianten für 1 von 1 Bildern.') + ->assertSuccessful(); + + expect($image->fresh()->variants)->not->toHaveKey('og'); + + $this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true, '--execute' => true]) + ->expectsOutputToContain('OG-Varianten erzeugt: 1') + ->assertSuccessful(); + + $og = $image->fresh()->variants['og'] ?? null; + expect($og)->not->toBeNull() + ->and(Storage::disk('local')->exists($og))->toBeTrue() + ->and(getimagesize(Storage::disk('local')->path($og))[0])->toBe(1200); +}); diff --git a/tests/Feature/Legal/LegalAcceptanceGateTest.php b/tests/Feature/Legal/LegalAcceptanceGateTest.php index a5ee78e8..cc8206a9 100644 --- a/tests/Feature/Legal/LegalAcceptanceGateTest.php +++ b/tests/Feature/Legal/LegalAcceptanceGateTest.php @@ -29,7 +29,7 @@ beforeEach(function (): void { /** * @return array{owner: User, pr: PressRelease} */ -function gateDraft(): array +function legalGateDraft(): array { $owner = User::factory()->create(['is_active' => true]); $owner->assignRole('customer'); @@ -49,7 +49,7 @@ function gateDraft(): array test('with the gate off nothing is required and submitting works as before', function () { /** @var TestCase $this */ - ['owner' => $owner, 'pr' => $pr] = gateDraft(); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty(); @@ -68,7 +68,7 @@ test('with the gate off nothing is required and submitting works as before', fun test('with the gate on the submit requires an active acceptance of agb and guidelines and records both', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); - ['owner' => $owner, 'pr' => $pr] = gateDraft(); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('type')->all())->toBe(['agb', 'richtlinien']); @@ -99,7 +99,7 @@ test('with the gate on the submit requires an active acceptance of agb and guide test('acceptances are insert-only', function () { config()->set('legal.acceptance_gate', true); - ['owner' => $owner] = gateDraft(); + ['owner' => $owner] = legalGateDraft(); $gate = app(LegalAcceptanceGate::class); $rows = $gate->accept($owner, $gate->requiredFor($owner), 'gate', '203.0.113.5', 'Pest'); @@ -112,7 +112,7 @@ test('acceptances are insert-only', function () { test('the api answers 428 with the required documents until they are accepted', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); - ['owner' => $owner, 'pr' => $pr] = gateDraft(); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']); $this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])