diff --git a/app/Services/DTO/LabelImportResult.php b/app/Services/DTO/LabelImportResult.php new file mode 100644 index 0000000..2279a4c --- /dev/null +++ b/app/Services/DTO/LabelImportResult.php @@ -0,0 +1,12 @@ +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())); + } +} diff --git a/app/Services/MacroResolutionService.php b/app/Services/MacroResolutionService.php new file mode 100644 index 0000000..3e8b56e --- /dev/null +++ b/app/Services/MacroResolutionService.php @@ -0,0 +1,85 @@ +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 + */ + 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', + ]; + } +} diff --git a/app/Services/MacrosImportService.php b/app/Services/MacrosImportService.php new file mode 100644 index 0000000..ed759df --- /dev/null +++ b/app/Services/MacrosImportService.php @@ -0,0 +1,110 @@ + 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(); + } +} diff --git a/tests/Feature/LabelsImportServiceTest.php b/tests/Feature/LabelsImportServiceTest.php new file mode 100644 index 0000000..7236a5c --- /dev/null +++ b/tests/Feature/LabelsImportServiceTest.php @@ -0,0 +1,53 @@ +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); +}); diff --git a/tests/Feature/MacroResolutionServiceTest.php b/tests/Feature/MacroResolutionServiceTest.php new file mode 100644 index 0000000..f5d9075 --- /dev/null +++ b/tests/Feature/MacroResolutionServiceTest.php @@ -0,0 +1,115 @@ +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); +}); diff --git a/tests/Feature/MacrosImportServiceTest.php b/tests/Feature/MacrosImportServiceTest.php new file mode 100644 index 0000000..a9038a3 --- /dev/null +++ b/tests/Feature/MacrosImportServiceTest.php @@ -0,0 +1,69 @@ +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'); +});