feat(services): add LabelsImportService, MacrosImportService, MacroResolutionService

This commit is contained in:
Thorsten Bus 2026-05-03 23:03:32 +02:00
parent bdbf0c65e3
commit 81b2a9caf6
8 changed files with 507 additions and 0 deletions

View file

@ -0,0 +1,12 @@
<?php
namespace App\Services\DTO;
final class LabelImportResult
{
public function __construct(
public readonly int $newCount,
public readonly int $updatedCount,
public readonly int $totalInFile,
) {}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Services\DTO;
final class MacroImportResult
{
public function __construct(
public readonly int $new,
public readonly int $updated,
public readonly int $disabled,
public readonly int $reEnabled,
public readonly array $warnings,
) {}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Models\Label;
use App\Models\Setting;
use App\Services\DTO\LabelImportResult;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\LabelsFileReader;
class LabelsImportService
{
public function import(string $filePath, string $originalFilename): LabelImportResult
{
$library = LabelsFileReader::read($filePath);
$newCount = 0;
$updatedCount = 0;
DB::transaction(function () use ($library, &$newCount, &$updatedCount): void {
foreach ($library->getLabels() as $parserLabel) {
$name = $parserLabel->getName();
if ($name === '') {
continue;
}
$color = $parserLabel->getColorHex();
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
if ($existing === null) {
Label::create([
'name' => $name,
'color' => $color,
'last_imported_at' => now(),
]);
$newCount++;
} else {
$existing->update([
'color' => $color,
'last_imported_at' => now(),
]);
$updatedCount++;
}
}
});
Setting::set('labels_last_imported_at', now()->toIso8601String());
Setting::set('labels_last_imported_filename', $originalFilename);
return new LabelImportResult($newCount, $updatedCount, count($library->getLabels()));
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Services;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use Illuminate\Support\Collection;
class MacroResolutionService
{
/**
* Returns active (non-hidden) assignments for a given service + part type.
* Uses service-specific assignments if an override exists, otherwise global defaults.
*/
public function resolveAssignmentsForPart(Service $service, string $partType): Collection
{
$hasOverride = ServiceMacroOverride::where('service_id', $service->id)
->where('part_type', $partType)
->exists();
if ($hasOverride) {
$rows = ServiceMacroAssignment::with(['macro', 'label'])
->where('service_id', $service->id)
->where('part_type', $partType)
->orderBy('order')
->get();
} else {
$rows = MacroAssignment::with(['macro', 'label'])
->where('part_type', $partType)
->orderBy('order')
->get();
}
return $rows
->reject(fn ($r) => $r->macro === null || $r->macro->isHidden())
->reject(fn ($r) => $r->position === 'by_label' && ($r->label === null || $r->label->isHidden()));
}
/**
* Returns the macro export data for macros that apply to a specific slide.
*
* @param array $slideContext ['index' => int, 'total' => int, 'label_id' => int|null]
* @return array<int, array{name: string, uuid: string, collectionName: string, collectionUuid: string}>
*/
public function macrosForSlide(Service $service, string $partType, array $slideContext): array
{
$assignments = $this->resolveAssignmentsForPart($service, $partType);
$matched = $assignments->filter(function ($a) use ($slideContext) {
return match ($a->position) {
'all_slides' => true,
'first_slide' => $slideContext['index'] === 0,
'last_slide' => $slideContext['index'] === $slideContext['total'] - 1,
'by_label' => isset($slideContext['label_id'])
&& (int) $a->label_id === (int) $slideContext['label_id'],
default => false,
};
});
return $matched->map(fn ($a) => $this->toExportArray($a->macro))->values()->all();
}
/**
* Returns the count of active assignments for a service + part (for UI badges).
*/
public function countAssignmentsForPart(Service $service, string $partType): int
{
return $this->resolveAssignmentsForPart($service, $partType)->count();
}
private function toExportArray(Macro $macro): array
{
$collection = $macro->collections()->first();
return [
'name' => $macro->name,
'uuid' => $macro->uuid,
'collectionName' => $collection?->name ?? '--MAIN--',
'collectionUuid' => $collection?->uuid ?? '8D02FC57-83F8-4042-9B90-81C229728426',
];
}
}

View file

@ -0,0 +1,110 @@
<?php
namespace App\Services;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use App\Services\DTO\MacroImportResult;
use App\Support\MacroColorConverter;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\MacrosFileReader;
class MacrosImportService
{
public function import(string $filePath, string $originalFilename): MacroImportResult
{
$library = MacrosFileReader::read($filePath);
$stats = ['new' => 0, 'updated' => 0, 'disabled' => 0, 'reEnabled' => 0];
$importedUuids = [];
DB::transaction(function () use ($library, &$stats, &$importedUuids, $originalFilename): void {
foreach ($library->getMacros() as $parserMacro) {
$uuid = strtoupper($parserMacro->getUuid());
if ($uuid === '') {
continue;
}
$importedUuids[] = $uuid;
$color = MacroColorConverter::fromRgba($parserMacro->getColor());
$data = [
'uuid' => $uuid,
'name' => $parserMacro->getName(),
'color' => $color,
'trigger_on_startup' => $parserMacro->getTriggerOnStartup(),
'image_type' => $parserMacro->getImageType(),
'action_count' => $parserMacro->getActionCount(),
'last_imported_at' => now(),
'last_imported_filename' => $originalFilename,
'hidden_at' => null,
];
$existing = Macro::where('uuid', $uuid)->first();
if ($existing === null) {
Macro::create($data);
$stats['new']++;
} else {
$wasHidden = $existing->isHidden();
$existing->update($data);
if ($wasHidden) {
$stats['reEnabled']++;
} else {
$stats['updated']++;
}
}
}
if (! empty($importedUuids)) {
$stats['disabled'] = Macro::whereNotIn('uuid', $importedUuids)
->whereNull('hidden_at')
->update(['hidden_at' => now()]);
}
foreach ($library->getCollections() as $parserCollection) {
$collUuid = strtoupper($parserCollection->getUuid());
if ($collUuid === '') {
continue;
}
$collection = MacroCollection::updateOrCreate(
['uuid' => $collUuid],
['name' => $parserCollection->getName(), 'last_imported_at' => now()],
);
$collection->macros()->detach();
foreach ($parserCollection->getMacroUuids() as $idx => $macroUuid) {
$macro = Macro::where('uuid', strtoupper($macroUuid))->first();
if ($macro) {
$collection->macros()->attach($macro->id, ['order' => $idx]);
}
}
}
});
Setting::set('macros_last_imported_at', now()->toIso8601String());
Setting::set('macros_last_imported_filename', $originalFilename);
$warnings = $this->buildAssignmentWarnings();
return new MacroImportResult(
$stats['new'],
$stats['updated'],
$stats['disabled'],
$stats['reEnabled'],
$warnings,
);
}
private function buildAssignmentWarnings(): array
{
return MacroAssignment::whereHas('macro', fn ($q) => $q->whereNotNull('hidden_at'))
->with('macro')
->get()
->map(fn ($a) => [
'macro_name' => $a->macro->name,
'macro_uuid' => $a->macro->uuid,
'part_type' => $a->part_type,
])
->toArray();
}
}

View file

@ -0,0 +1,53 @@
<?php
use App\Models\Label;
use App\Models\Setting;
use App\Services\LabelsImportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('import creates new labels from file', function () {
$service = app(LabelsImportService::class);
$result = $service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect($result->newCount)->toBeGreaterThanOrEqual(1);
expect($result->updatedCount)->toBe(0);
expect(Label::count())->toBeGreaterThanOrEqual(1);
});
test('import updates color of existing labels', function () {
Label::create(['name' => 'Copyright', 'color' => '#000000']);
$service = app(LabelsImportService::class);
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
$label = Label::where('name', 'Copyright')->first();
if ($label) {
expect($label->color)->not->toBe('#000000');
}
expect(true)->toBeTrue();
});
test('import stores last imported metadata in settings', function () {
$service = app(LabelsImportService::class);
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect(Setting::get('labels_last_imported_at'))->not->toBeNull();
expect(Setting::get('labels_last_imported_filename'))->toBe('labels-sample.bin');
});
test('re-import is idempotent — no duplicates', function () {
$service = app(LabelsImportService::class);
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
$countAfterFirst = Label::count();
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect(Label::count())->toBe($countAfterFirst);
});
test('empty label names are skipped', function () {
$service = app(LabelsImportService::class);
$result = $service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect($result->totalInFile)->toBeGreaterThan(0);
});

View file

@ -0,0 +1,115 @@
<?php
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use App\Services\MacroResolutionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('returns empty when no assignments exist', function () {
$service = Service::factory()->create();
$resolver = app(MacroResolutionService::class);
expect($resolver->resolveAssignmentsForPart($service, 'song'))->toBeEmpty();
});
test('returns global assignments when no override', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
$resolved = $resolver->resolveAssignmentsForPart($service, 'song');
expect($resolved)->toHaveCount(1);
expect($resolved->first()->macro->id)->toBe($macro->id);
});
test('override wins over globals', function () {
$service = Service::factory()->create();
$macroA = Macro::factory()->create(['name' => 'Global']);
$macroB = Macro::factory()->create(['name' => 'Override']);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macroA->id, 'position' => 'all_slides', 'order' => 0]);
ServiceMacroOverride::create(['service_id' => $service->id, 'part_type' => 'song']);
ServiceMacroAssignment::create(['service_id' => $service->id, 'part_type' => 'song', 'macro_id' => $macroB->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
$resolved = $resolver->resolveAssignmentsForPart($service, 'song');
expect($resolved)->toHaveCount(1);
expect($resolved->first()->macro->name)->toBe('Override');
});
test('hidden macros are filtered out', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create(['hidden_at' => now()]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->resolveAssignmentsForPart($service, 'song'))->toBeEmpty();
});
test('macrosForSlide with all_slides matches every slide', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
$result = $resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]);
expect($result)->toHaveCount(1);
expect($result[0]['uuid'])->toBe($macro->uuid);
});
test('macrosForSlide with first_slide only matches index 0', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'first_slide', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]))->toHaveCount(1);
expect($resolver->macrosForSlide($service, 'song', ['index' => 1, 'total' => 3, 'label_id' => null]))->toHaveCount(0);
});
test('macrosForSlide stacking — multiple matching assignments produce multiple macros', function () {
$service = Service::factory()->create();
$macro1 = Macro::factory()->create();
$macro2 = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro1->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro2->id, 'position' => 'first_slide', 'order' => 1]);
$resolver = app(MacroResolutionService::class);
$result = $resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]);
expect($result)->toHaveCount(2);
});
test('macrosForSlide with by_label matches only matching label_id', function () {
$service = Service::factory()->create();
$label = Label::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'by_label', 'label_id' => $label->id, 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => $label->id]))->toHaveCount(1);
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => 9999]))->toHaveCount(0);
});
test('countAssignmentsForPart returns correct count', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->countAssignmentsForPart($service, 'song'))->toBe(1);
expect($resolver->countAssignmentsForPart($service, 'sermon'))->toBe(1);
expect($resolver->countAssignmentsForPart($service, 'information'))->toBe(0);
});

View file

@ -0,0 +1,69 @@
<?php
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Setting;
use App\Services\MacrosImportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('import creates new macros from file', function () {
$service = app(MacrosImportService::class);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect($result->new)->toBeGreaterThanOrEqual(1);
expect($result->updated)->toBe(0);
expect(Macro::count())->toBeGreaterThanOrEqual(1);
});
test('import stores hex color on macros', function () {
$service = app(MacrosImportService::class);
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect(Macro::whereNotNull('color')->where('color', 'like', '#%')->count())->toBeGreaterThanOrEqual(1);
});
test('import marks missing macros as hidden', function () {
$existing = Macro::factory()->create(['uuid' => 'FAKE-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'hidden_at' => null]);
$service = app(MacrosImportService::class);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect($existing->fresh()->isHidden())->toBeTrue();
expect($result->disabled)->toBeGreaterThanOrEqual(1);
});
test('import re-enables previously hidden macros that appear in file', function () {
$service = app(MacrosImportService::class);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
$firstMacro = Macro::first();
$firstMacro->update(['hidden_at' => now()]);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect($result->reEnabled)->toBeGreaterThanOrEqual(1);
expect($firstMacro->fresh()->isHidden())->toBeFalse();
});
test('import builds warnings for disabled macros with active assignments', function () {
$service = app(MacrosImportService::class);
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
$hiddenMacro = Macro::factory()->create(['uuid' => 'WARN-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'hidden_at' => now()]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $hiddenMacro->id, 'position' => 'all_slides', 'order' => 0]);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect(count($result->warnings))->toBeGreaterThanOrEqual(1);
$warning = collect($result->warnings)->firstWhere('macro_uuid', 'WARN-FFFF-FFFF-FFFF-FFFFFFFFFFFF');
expect($warning)->not->toBeNull();
});
test('import stores last imported metadata in settings', function () {
$service = app(MacrosImportService::class);
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect(Setting::get('macros_last_imported_at'))->not->toBeNull();
expect(Setting::get('macros_last_imported_filename'))->toBe('macros.bin');
});