diff --git a/.sisyphus/notepads/cts-presenter-app/learnings.md b/.sisyphus/notepads/cts-presenter-app/learnings.md
index 2f56e17..05113be 100644
--- a/.sisyphus/notepads/cts-presenter-app/learnings.md
+++ b/.sisyphus/notepads/cts-presenter-app/learnings.md
@@ -1,3 +1,16 @@
- 2026-03-01: Fuer 1920x1080 Slide-Output ohne Upscaling funktioniert in Intervention Image v3 die Kombination aus schwarzer Canvas (`create()->fill('000000')`), `scaleDown(width: 1920, height: 1080)` und zentriertem `place(...)` stabil.
- 2026-03-01: Bei Fake-Storage in Tests muessen Zielordner vor direktem Intervention-`save()` explizit erstellt werden (`makeDirectory`/`mkdir`), sonst wirft Intervention `NotWritableException`.
- 2026-03-01: Fuer Testverifikation von Letterbox/Pillarbox sind farbige PNG-Testbilder sinnvoller als `UploadedFile::fake()->image(...)`, weil Fake-Bilder sonst komplett schwarz sein koennen.
+- 2026-03-01: CTS-Sync laeuft stabil mit `EventRequest::where("from", heute)` + `EventAgendaRequest::fromEvent(...)->get()`, wenn Services per `cts_event_id` und Agenda-Songs per (`service_id`,`order`) upserted werden; CCLI-Matching bleibt strikt auf `songs.ccli_id` und setzt nur dann `song_id`/`matched_at`.
+- 2026-03-01: SongController CRUD nutzt `auth:sanctum` Middleware; `actingAs()` in Tests funktioniert damit problemlos (Sanctum unterstuetzt Session-Auth in Tests).
+- 2026-03-01: SQLite gibt `date`-Spalten als `YYYY-MM-DD 00:00:00` zurueck statt `YYYY-MM-DD` — Accessor muss `substr($date, 0, 10)` nutzen fuer saubere Date-Only Werte.
+- 2026-03-01: `Attribute::get()` in Laravel 12 fuer berechnete Accessors statt altem `get{Name}Attribute()` Pattern. Snake_case `last_used_in_service` mapped automatisch auf `lastUsedInService()` Methode.
+- 2026-03-01: Default-Gruppen (Strophe 1=#3B82F6, Refrain=#10B981, Bridge=#F59E0B) und Default-Arrangement 'Normal' werden automatisch bei Song-Erstellung via SongService erzeugt.
+- 2026-03-01: `Rule::unique('songs', 'ccli_id')->ignore($songId)->whereNull('deleted_at')` stellt sicher, dass Soft-Deleted Songs die Unique-Constraint nicht blockieren.
+- 2026-03-01: `bootstrap/app.php` braucht explizit `api: __DIR__.'/../routes/api.php'` in `withRouting()` — ist nicht automatisch registriert in Laravel 12.
+- 2026-03-01: Service-Listenstatus laesst sich performant in einem Query aggregieren via `withCount(...)` fuer Song-Metriken plus `addSelect`-Subqueries fuer `has_sermon_slides` und datumsabhaengige `info_slides_count` (inkl. globaler `information`-Slides mit `service_id = null`).
+- 2026-03-01: TranslationService line-count distribution: iterate groups (by order) → slides (by order), for each slide count lines in `text_content`, then slice that many lines from the translated text array. `array_slice` + offset tracking works cleanly.
+- 2026-03-01: URL scraping is best-effort only: `Http::timeout(10)->get($url)` + `strip_tags()` + `trim()`. Return null on any failure — no exceptions bubble up. PHP 8.1+ allows `catch (\Exception)` without variable capture.
+- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
+- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
+- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
diff --git a/app/Http/Controllers/ArrangementController.php b/app/Http/Controllers/ArrangementController.php
new file mode 100644
index 0000000..3de74b1
--- /dev/null
+++ b/app/Http/Controllers/ArrangementController.php
@@ -0,0 +1,164 @@
+validate([
+ 'name' => ['required', 'string', 'max:255'],
+ ]);
+
+ DB::transaction(function () use ($song, $data): void {
+ $arrangement = $song->arrangements()->create([
+ 'name' => $data['name'],
+ 'is_default' => false,
+ ]);
+
+ $source = $song->arrangements()
+ ->where('is_default', true)
+ ->with('arrangementGroups')
+ ->first();
+
+ if ($source === null) {
+ $source = $song->arrangements()
+ ->with('arrangementGroups')
+ ->orderBy('id')
+ ->first();
+ }
+
+ $this->cloneGroups($source, $arrangement);
+ });
+
+ return back()->with('success', 'Arrangement wurde hinzugefügt.');
+ }
+
+ public function clone(Request $request, SongArrangement $arrangement): RedirectResponse
+ {
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:255'],
+ ]);
+
+ DB::transaction(function () use ($arrangement, $data): void {
+ $arrangement->loadMissing('arrangementGroups');
+
+ $clone = $arrangement->song->arrangements()->create([
+ 'name' => $data['name'],
+ 'is_default' => false,
+ ]);
+
+ $this->cloneGroups($arrangement, $clone);
+ });
+
+ return back()->with('success', 'Arrangement wurde geklont.');
+ }
+
+ public function update(Request $request, SongArrangement $arrangement): RedirectResponse
+ {
+ $data = $request->validate([
+ 'groups' => ['array'],
+ 'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
+ 'groups.*.order' => ['required', 'integer', 'min:1'],
+ 'group_colors' => ['sometimes', 'array'],
+ 'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
+ ]);
+
+ $groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
+ $uniqueGroupIds = $groupIds->unique()->values();
+
+ $validGroupIds = $arrangement->song->groups()
+ ->whereIn('id', $uniqueGroupIds)
+ ->pluck('id');
+
+ if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
+ throw ValidationException::withMessages([
+ 'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
+ ]);
+ }
+
+ DB::transaction(function () use ($arrangement, $groupIds, $data): void {
+ $arrangement->arrangementGroups()->delete();
+
+ $rows = $groupIds
+ ->values()
+ ->map(fn (int $songGroupId, int $index) => [
+ 'song_arrangement_id' => $arrangement->id,
+ 'song_group_id' => $songGroupId,
+ 'order' => $index + 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ])
+ ->all();
+
+ if ($rows !== []) {
+ $arrangement->arrangementGroups()->insert($rows);
+ }
+
+ if (!empty($data['group_colors'])) {
+ foreach ($data['group_colors'] as $groupId => $color) {
+ $arrangement->song->groups()
+ ->whereKey((int) $groupId)
+ ->update(['color' => $color]);
+ }
+ }
+ });
+
+ return back()->with('success', 'Arrangement wurde gespeichert.');
+ }
+
+ public function destroy(SongArrangement $arrangement): RedirectResponse
+ {
+ $song = $arrangement->song;
+
+ if ($song->arrangements()->count() <= 1) {
+ return back()->with('error', 'Das letzte Arrangement kann nicht gelöscht werden.');
+ }
+
+ DB::transaction(function () use ($arrangement, $song): void {
+ $deletedWasDefault = $arrangement->is_default;
+ $arrangement->delete();
+
+ if ($deletedWasDefault) {
+ $song->arrangements()
+ ->orderBy('id')
+ ->limit(1)
+ ->update(['is_default' => true]);
+ }
+ });
+
+ return back()->with('success', 'Arrangement wurde gelöscht.');
+ }
+
+ private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
+ {
+ if ($source === null) {
+ return;
+ }
+
+ $groups = $source->arrangementGroups
+ ->sortBy('order')
+ ->values();
+
+ $rows = $groups
+ ->map(fn ($arrangementGroup) => [
+ 'song_arrangement_id' => $target->id,
+ 'song_group_id' => $arrangementGroup->song_group_id,
+ 'order' => $arrangementGroup->order,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ])
+ ->all();
+
+ if ($rows !== []) {
+ $target->arrangementGroups()->insert($rows);
+ }
+ }
+}
diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php
new file mode 100644
index 0000000..3d30237
--- /dev/null
+++ b/app/Http/Controllers/ServiceController.php
@@ -0,0 +1,84 @@
+whereDate('date', '>=', Carbon::today())
+ ->orderBy('date')
+ ->withCount([
+ 'serviceSongs as songs_total_count',
+ 'serviceSongs as songs_mapped_count' => fn ($query) => $query->whereNotNull('song_id'),
+ 'serviceSongs as songs_arranged_count' => fn ($query) => $query->whereNotNull('song_arrangement_id'),
+ ])
+ ->addSelect([
+ 'has_sermon_slides' => Slide::query()
+ ->selectRaw('CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END')
+ ->whereColumn('slides.service_id', 'services.id')
+ ->where('slides.type', 'sermon'),
+ 'info_slides_count' => Slide::query()
+ ->selectRaw('COUNT(*)')
+ ->where('slides.type', 'information')
+ ->where(function ($query) {
+ $query
+ ->whereNull('slides.service_id')
+ ->orWhereColumn('slides.service_id', 'services.id');
+ })
+ ->whereNotNull('slides.expire_date')
+ ->whereColumn('slides.expire_date', '>=', 'services.date'),
+ ])
+ ->get()
+ ->map(fn (Service $service) => [
+ 'id' => $service->id,
+ 'title' => $service->title,
+ 'date' => $service->date?->toDateString(),
+ 'preacher_name' => $service->preacher_name,
+ 'beamer_tech_name' => $service->beamer_tech_name,
+ 'last_synced_at' => $service->last_synced_at?->toJSON(),
+ 'updated_at' => $service->updated_at?->toJSON(),
+ 'finalized_at' => $service->finalized_at?->toJSON(),
+ 'songs_total_count' => (int) $service->songs_total_count,
+ 'songs_mapped_count' => (int) $service->songs_mapped_count,
+ 'songs_arranged_count' => (int) $service->songs_arranged_count,
+ 'has_sermon_slides' => (bool) $service->has_sermon_slides,
+ 'info_slides_count' => (int) $service->info_slides_count,
+ ])
+ ->values();
+
+ return Inertia::render('Services/Index', [
+ 'services' => $services,
+ ]);
+ }
+
+ public function finalize(Service $service): RedirectResponse
+ {
+ $service->update([
+ 'finalized_at' => now(),
+ ]);
+
+ return redirect()
+ ->route('services.index')
+ ->with('success', 'Service wurde abgeschlossen.');
+ }
+
+ public function reopen(Service $service): RedirectResponse
+ {
+ $service->update([
+ 'finalized_at' => null,
+ ]);
+
+ return redirect()
+ ->route('services.index')
+ ->with('success', 'Service wurde wieder geoeffnet.');
+ }
+}
diff --git a/app/Http/Controllers/ServiceSongController.php b/app/Http/Controllers/ServiceSongController.php
new file mode 100644
index 0000000..99e11d1
--- /dev/null
+++ b/app/Http/Controllers/ServiceSongController.php
@@ -0,0 +1,67 @@
+validate([
+ 'song_id' => ['required', 'integer', 'exists:songs,id'],
+ ]);
+
+ $serviceSong = ServiceSong::findOrFail($serviceSongId);
+ $song = Song::findOrFail($validated['song_id']);
+
+ $this->songMatchingService->manualAssign($serviceSong, $song);
+
+ return response()->json([
+ 'message' => 'Song erfolgreich zugeordnet',
+ 'service_song' => $serviceSong->fresh(),
+ ]);
+ }
+
+ /**
+ * E-Mail-Anfrage für fehlenden Song senden.
+ */
+ public function requestSong(int $serviceSongId): JsonResponse
+ {
+ $serviceSong = ServiceSong::findOrFail($serviceSongId);
+
+ $this->songMatchingService->requestCreation($serviceSong);
+
+ return response()->json([
+ 'message' => 'Anfrage wurde gesendet',
+ 'service_song' => $serviceSong->fresh(),
+ ]);
+ }
+
+ /**
+ * Song-Zuordnung entfernen.
+ */
+ public function unassign(int $serviceSongId): JsonResponse
+ {
+ $serviceSong = ServiceSong::findOrFail($serviceSongId);
+
+ $this->songMatchingService->unassign($serviceSong);
+
+ return response()->json([
+ 'message' => 'Zuordnung entfernt',
+ 'service_song' => $serviceSong->fresh(),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/SlideController.php b/app/Http/Controllers/SlideController.php
new file mode 100644
index 0000000..0e7574d
--- /dev/null
+++ b/app/Http/Controllers/SlideController.php
@@ -0,0 +1,214 @@
+validate([
+ 'file' => ['required', 'file', 'max:51200'],
+ 'type' => ['required', Rule::in(['information', 'moderation', 'sermon'])],
+ 'service_id' => ['nullable', 'exists:services,id'],
+ 'expire_date' => ['nullable', 'date'],
+ ]);
+
+ // moderation and sermon slides require a service_id
+ if (in_array($validated['type'], ['moderation', 'sermon']) && empty($validated['service_id'])) {
+ return response()->json([
+ 'message' => 'Moderations- und Predigtfolien benötigen einen Gottesdienst.',
+ 'errors' => ['service_id' => ['Gottesdienst ist erforderlich.']],
+ ], 422);
+ }
+
+ /** @var UploadedFile $file */
+ $file = $request->file('file');
+ $extension = strtolower($file->getClientOriginalExtension());
+
+ // Validate file extension
+ $allowedExtensions = [...self::IMAGE_EXTENSIONS, ...self::POWERPOINT_EXTENSIONS, 'zip'];
+ if (! in_array($extension, $allowedExtensions, true)) {
+ return response()->json([
+ 'message' => 'Dateityp nicht erlaubt.',
+ 'errors' => ['file' => ['Nur PNG, JPG, PPT, PPTX und ZIP-Dateien sind erlaubt.']],
+ ], 422);
+ }
+
+ $uploaderName = $request->user()?->name ?? 'Unbekannt';
+ $serviceId = $validated['service_id'] ?? null;
+ $type = $validated['type'];
+ $expireDate = $validated['expire_date'] ?? null;
+
+ // Handle PowerPoint files — dispatch async job
+ if (in_array($extension, self::POWERPOINT_EXTENSIONS, true)) {
+ return $this->handlePowerPoint($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
+ }
+
+ // Handle ZIP files — extract and process
+ if ($extension === 'zip') {
+ return $this->handleZip($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
+ }
+
+ // Handle images — convert synchronously
+ return $this->handleImage($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
+ }
+
+ public function destroy(Slide $slide): JsonResponse
+ {
+ $slide->delete();
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Folie wurde gelöscht.',
+ ]);
+ }
+
+ public function updateExpireDate(Request $request, Slide $slide): JsonResponse
+ {
+ if ($slide->type !== 'information') {
+ return response()->json([
+ 'message' => 'Ablaufdatum kann nur für Informationsfolien gesetzt werden.',
+ 'errors' => ['type' => ['Nur Informationsfolien haben ein Ablaufdatum.']],
+ ], 422);
+ }
+
+ $validated = $request->validate([
+ 'expire_date' => ['nullable', 'date'],
+ ]);
+
+ $slide->update(['expire_date' => $validated['expire_date']]);
+
+ return response()->json([
+ 'success' => true,
+ 'slide' => $slide->fresh(),
+ ]);
+ }
+
+ private function handleImage(
+ UploadedFile $file,
+ FileConversionService $conversionService,
+ string $type,
+ ?int $serviceId,
+ string $uploaderName,
+ ?string $expireDate,
+ ): JsonResponse {
+ try {
+ $result = $conversionService->convertImage($file);
+
+ $slide = Slide::create([
+ 'type' => $type,
+ 'service_id' => $serviceId,
+ 'original_filename' => $file->getClientOriginalName(),
+ 'stored_filename' => $result['filename'],
+ 'thumbnail_filename' => $result['thumbnail'],
+ 'expire_date' => $expireDate,
+ 'uploader_name' => $uploaderName,
+ 'uploaded_at' => now(),
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'slide' => $slide,
+ ]);
+ } catch (InvalidArgumentException $e) {
+ return response()->json([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ], 422);
+ }
+ }
+
+ private function handlePowerPoint(
+ UploadedFile $file,
+ FileConversionService $conversionService,
+ string $type,
+ ?int $serviceId,
+ string $uploaderName,
+ ?string $expireDate,
+ ): JsonResponse {
+ // Store file persistently so the job can access it
+ $storedPath = $file->store('temp/ppt', 'local');
+
+ try {
+ $jobId = $conversionService->convertPowerPoint(
+ storage_path('app/' . $storedPath)
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'job_id' => $jobId,
+ 'message' => 'PowerPoint wird verarbeitet...',
+ 'meta' => [
+ 'type' => $type,
+ 'service_id' => $serviceId,
+ 'uploader_name' => $uploaderName,
+ 'expire_date' => $expireDate,
+ 'original_filename' => $file->getClientOriginalName(),
+ ],
+ ]);
+ } catch (InvalidArgumentException $e) {
+ return response()->json([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ], 422);
+ }
+ }
+
+ private function handleZip(
+ UploadedFile $file,
+ FileConversionService $conversionService,
+ string $type,
+ ?int $serviceId,
+ string $uploaderName,
+ ?string $expireDate,
+ ): JsonResponse {
+ try {
+ $results = $conversionService->processZip($file);
+ $slides = [];
+
+ foreach ($results as $result) {
+ // Skip PPT job results (they are handled asynchronously)
+ if (isset($result['job_id'])) {
+ continue;
+ }
+
+ $slides[] = Slide::create([
+ 'type' => $type,
+ 'service_id' => $serviceId,
+ 'original_filename' => $file->getClientOriginalName(),
+ 'stored_filename' => $result['filename'],
+ 'thumbnail_filename' => $result['thumbnail'],
+ 'expire_date' => $expireDate,
+ 'uploader_name' => $uploaderName,
+ 'uploaded_at' => now(),
+ ]);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'slides' => $slides,
+ 'count' => count($slides),
+ ]);
+ } catch (InvalidArgumentException $e) {
+ Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]);
+
+ return response()->json([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ], 422);
+ }
+ }
+}
diff --git a/app/Http/Controllers/SongController.php b/app/Http/Controllers/SongController.php
new file mode 100644
index 0000000..1913950
--- /dev/null
+++ b/app/Http/Controllers/SongController.php
@@ -0,0 +1,173 @@
+input('search')) {
+ $query->where(function ($q) use ($search) {
+ $q->where('title', 'like', "%{$search}%")
+ ->orWhere('ccli_id', 'like', "%{$search}%");
+ });
+ }
+
+ $songs = $query->orderBy('title')
+ ->paginate($request->input('per_page', 20));
+
+ return response()->json([
+ 'data' => $songs->map(fn (Song $song) => [
+ 'id' => $song->id,
+ 'title' => $song->title,
+ 'ccli_id' => $song->ccli_id,
+ 'author' => $song->author,
+ 'has_translation' => $song->has_translation,
+ 'last_used_at' => $song->last_used_at?->toDateString(),
+ 'last_used_in_service' => $song->last_used_in_service,
+ 'created_at' => $song->created_at->toDateTimeString(),
+ 'updated_at' => $song->updated_at->toDateTimeString(),
+ ]),
+ 'meta' => [
+ 'current_page' => $songs->currentPage(),
+ 'last_page' => $songs->lastPage(),
+ 'per_page' => $songs->perPage(),
+ 'total' => $songs->total(),
+ ],
+ ]);
+ }
+
+ /**
+ * Neuen Song erstellen mit Default-Gruppen und -Arrangement.
+ */
+ public function store(SongRequest $request): JsonResponse
+ {
+ $song = DB::transaction(function () use ($request) {
+ $song = Song::create($request->validated());
+
+ $this->songService->createDefaultGroups($song);
+ $this->songService->createDefaultArrangement($song);
+
+ return $song;
+ });
+
+ return response()->json([
+ 'message' => 'Song erfolgreich erstellt',
+ 'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
+ ], 201);
+ }
+
+ /**
+ * Song mit Gruppen, Slides und Arrangements anzeigen.
+ */
+ public function show(int $id): JsonResponse
+ {
+ $song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
+
+ if (! $song) {
+ return response()->json(['message' => 'Song nicht gefunden'], 404);
+ }
+
+ return response()->json([
+ 'data' => $this->formatSongDetail($song),
+ ]);
+ }
+
+ /**
+ * Song-Metadaten aktualisieren.
+ */
+ public function update(SongRequest $request, int $id): JsonResponse
+ {
+ $song = Song::find($id);
+
+ if (! $song) {
+ return response()->json(['message' => 'Song nicht gefunden'], 404);
+ }
+
+ $song->update($request->validated());
+
+ return response()->json([
+ 'message' => 'Song erfolgreich aktualisiert',
+ 'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
+ ]);
+ }
+
+ /**
+ * Song soft-löschen.
+ */
+ public function destroy(int $id): JsonResponse
+ {
+ $song = Song::find($id);
+
+ if (! $song) {
+ return response()->json(['message' => 'Song nicht gefunden'], 404);
+ }
+
+ $song->delete();
+
+ return response()->json([
+ 'message' => 'Song erfolgreich gelöscht',
+ ]);
+ }
+
+ /**
+ * Song-Detail formatieren.
+ */
+ private function formatSongDetail(Song $song): array
+ {
+ return [
+ 'id' => $song->id,
+ 'title' => $song->title,
+ 'ccli_id' => $song->ccli_id,
+ 'author' => $song->author,
+ 'copyright_text' => $song->copyright_text,
+ 'copyright_year' => $song->copyright_year,
+ 'publisher' => $song->publisher,
+ 'has_translation' => $song->has_translation,
+ 'last_used_at' => $song->last_used_at?->toDateString(),
+ 'last_used_in_service' => $song->last_used_in_service,
+ 'created_at' => $song->created_at->toDateTimeString(),
+ 'updated_at' => $song->updated_at->toDateTimeString(),
+ 'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
+ 'id' => $group->id,
+ 'name' => $group->name,
+ 'color' => $group->color,
+ 'order' => $group->order,
+ 'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
+ 'id' => $slide->id,
+ 'order' => $slide->order,
+ 'text_content' => $slide->text_content,
+ 'text_content_translated' => $slide->text_content_translated,
+ 'notes' => $slide->notes,
+ ])->toArray(),
+ ])->toArray(),
+ 'arrangements' => $song->arrangements->map(fn ($arr) => [
+ 'id' => $arr->id,
+ 'name' => $arr->name,
+ 'is_default' => $arr->is_default,
+ 'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
+ 'id' => $ag->id,
+ 'song_group_id' => $ag->song_group_id,
+ 'order' => $ag->order,
+ ])->toArray(),
+ ])->toArray(),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/TranslationController.php b/app/Http/Controllers/TranslationController.php
new file mode 100644
index 0000000..647e42f
--- /dev/null
+++ b/app/Http/Controllers/TranslationController.php
@@ -0,0 +1,87 @@
+validate([
+ 'url' => ['required', 'url'],
+ ]);
+
+ $text = $this->translationService->fetchFromUrl($request->input('url'));
+
+ if ($text === null) {
+ return response()->json([
+ 'message' => 'Konnte Text nicht abrufen',
+ ], 422);
+ }
+
+ return response()->json([
+ 'text' => $text,
+ ]);
+ }
+
+ /**
+ * Übersetzungstext für einen Song importieren.
+ *
+ * Verteilt den Text zeilenweise auf die Slides des Songs.
+ */
+ public function import(int $songId, Request $request): JsonResponse
+ {
+ $song = Song::find($songId);
+
+ if (! $song) {
+ return response()->json([
+ 'message' => 'Song nicht gefunden',
+ ], 404);
+ }
+
+ $request->validate([
+ 'text' => ['required', 'string'],
+ ]);
+
+ $this->translationService->importTranslation($song, $request->input('text'));
+
+ return response()->json([
+ 'message' => 'Übersetzung erfolgreich importiert',
+ ]);
+ }
+
+ /**
+ * Übersetzung eines Songs komplett entfernen.
+ */
+ public function destroy(int $songId): JsonResponse
+ {
+ $song = Song::find($songId);
+
+ if (! $song) {
+ return response()->json([
+ 'message' => 'Song nicht gefunden',
+ ], 404);
+ }
+
+ $this->translationService->removeTranslation($song);
+
+ return response()->json([
+ 'message' => 'Übersetzung entfernt',
+ ]);
+ }
+}
diff --git a/app/Http/Requests/SongRequest.php b/app/Http/Requests/SongRequest.php
new file mode 100644
index 0000000..03a6154
--- /dev/null
+++ b/app/Http/Requests/SongRequest.php
@@ -0,0 +1,37 @@
+|string>
+ */
+ public function rules(): array
+ {
+ $songId = $this->route('song');
+
+ return [
+ 'title' => ['required', 'string', 'max:255'],
+ 'ccli_id' => [
+ 'nullable',
+ 'string',
+ 'max:50',
+ Rule::unique('songs', 'ccli_id')->ignore($songId)->whereNull('deleted_at'),
+ ],
+ 'author' => ['nullable', 'string', 'max:255'],
+ 'copyright_text' => ['nullable', 'string', 'max:1000'],
+ 'copyright_year' => ['nullable', 'string', 'max:10'],
+ 'publisher' => ['nullable', 'string', 'max:255'],
+ 'has_translation' => ['nullable', 'boolean'],
+ ];
+ }
+}
diff --git a/app/Models/Song.php b/app/Models/Song.php
index 30c2472..175405e 100644
--- a/app/Models/Song.php
+++ b/app/Models/Song.php
@@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Database\Eloquent\Casts\Attribute;
class Song extends Model
{
@@ -45,4 +46,24 @@ public function serviceSongs(): HasMany
{
return $this->hasMany(ServiceSong::class);
}
+
+ /**
+ * Letztes Service-Datum, in dem dieser Song verwendet wurde.
+ * Berechnet über die service_songs -> services Verknüpfung.
+ */
+ public function lastUsedInService(): Attribute
+ {
+ return Attribute::get(function () {
+ $latestDate = $this->serviceSongs()
+ ->join('services', 'services.id', '=', 'service_songs.service_id')
+ ->max('services.date');
+
+ if ($latestDate === null) {
+ return null;
+ }
+
+ // SQLite gibt date-Spalten als 'YYYY-MM-DD 00:00:00' zurück
+ return substr((string) $latestDate, 0, 10);
+ });
+ }
}
diff --git a/app/Services/ChurchToolsService.php b/app/Services/ChurchToolsService.php
index f07d783..97dfde5 100644
--- a/app/Services/ChurchToolsService.php
+++ b/app/Services/ChurchToolsService.php
@@ -214,6 +214,8 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
'unmatched_songs_count' => 0,
];
+ $songMatchingService = app(SongMatchingService::class);
+
foreach ($agendaSongs as $index => $song) {
$ctsSongId = (string) ($song->getId() ?? '');
if ($ctsSongId === '') {
@@ -221,9 +223,6 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
}
$ccliId = $this->normalizeCcli($song->getCcli() ?? null);
- $matchedSong = $ccliId === null
- ? null
- : DB::table('songs')->where('ccli_id', $ccliId)->first();
DB::table('service_songs')->updateOrInsert(
[
@@ -233,16 +232,26 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
[
'cts_song_name' => (string) ($song->getName() ?? ''),
'cts_ccli_id' => $ccliId,
- 'song_id' => $matchedSong?->id,
- 'matched_at' => $matchedSong !== null ? Carbon::now() : null,
'updated_at' => Carbon::now(),
'created_at' => Carbon::now(),
]
);
+ $serviceSong = \App\Models\ServiceSong::where('service_id', $serviceId)
+ ->where('order', $index + 1)
+ ->first();
+
+ $matched = false;
+
+ if ($serviceSong !== null && $serviceSong->cts_ccli_id && ! $serviceSong->song_id) {
+ $matched = $songMatchingService->autoMatch($serviceSong);
+ } elseif ($serviceSong !== null && $serviceSong->song_id) {
+ $matched = true;
+ }
+
$summary['songs_count']++;
- if ($matchedSong !== null) {
+ if ($matched) {
$summary['matched_songs_count']++;
} else {
$summary['unmatched_songs_count']++;
diff --git a/app/Services/SongMatchingService.php b/app/Services/SongMatchingService.php
new file mode 100644
index 0000000..6768030
--- /dev/null
+++ b/app/Services/SongMatchingService.php
@@ -0,0 +1,86 @@
+song_id !== null) {
+ return false;
+ }
+
+ if ($serviceSong->cts_ccli_id === null || $serviceSong->cts_ccli_id === '') {
+ return false;
+ }
+
+ $song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first();
+
+ if ($song === null) {
+ return false;
+ }
+
+ $serviceSong->update([
+ 'song_id' => $song->id,
+ 'matched_at' => Carbon::now(),
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Manually assign a song to a service song.
+ * Overwrites any existing assignment.
+ */
+ public function manualAssign(ServiceSong $serviceSong, Song $song): void
+ {
+ $serviceSong->update([
+ 'song_id' => $song->id,
+ 'matched_at' => Carbon::now(),
+ ]);
+ }
+
+ /**
+ * Send a missing song request email and record the timestamp.
+ */
+ public function requestCreation(ServiceSong $serviceSong): void
+ {
+ $service = $serviceSong->service;
+
+ $recipientEmail = (string) Config::get('services.song_request.email', 'songs@example.com');
+
+ Mail::to($recipientEmail)->send(new MissingSongRequest(
+ songName: $serviceSong->cts_song_name,
+ ccliId: $serviceSong->cts_ccli_id,
+ service: $service,
+ ));
+
+ $serviceSong->update([
+ 'request_sent_at' => Carbon::now(),
+ ]);
+ }
+
+ /**
+ * Remove song assignment from a service song.
+ */
+ public function unassign(ServiceSong $serviceSong): void
+ {
+ $serviceSong->update([
+ 'song_id' => null,
+ 'matched_at' => null,
+ ]);
+ }
+}
diff --git a/app/Services/SongService.php b/app/Services/SongService.php
new file mode 100644
index 0000000..cca4ac7
--- /dev/null
+++ b/app/Services/SongService.php
@@ -0,0 +1,77 @@
+
+ */
+ public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
+ {
+ $defaults = [
+ ['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
+ ['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
+ ['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
+ ];
+
+ foreach ($defaults as $groupData) {
+ $song->groups()->create($groupData);
+ }
+
+ return $song->groups()->orderBy('order')->get();
+ }
+
+ /**
+ * Standard "Normal"-Arrangement mit allen Gruppen erstellen.
+ */
+ public function createDefaultArrangement(Song $song): SongArrangement
+ {
+ $arrangement = $song->arrangements()->create([
+ 'name' => 'Normal',
+ 'is_default' => true,
+ ]);
+
+ $groups = $song->groups()->orderBy('order')->get();
+
+ foreach ($groups as $index => $group) {
+ $arrangement->arrangementGroups()->create([
+ 'song_group_id' => $group->id,
+ 'order' => $index + 1,
+ ]);
+ }
+
+ return $arrangement->load('arrangementGroups');
+ }
+
+ /**
+ * Arrangement duplizieren mit neuem Namen.
+ */
+ public function duplicateArrangement(SongArrangement $arrangement, string $name): SongArrangement
+ {
+ return DB::transaction(function () use ($arrangement, $name) {
+ $clone = $arrangement->replicate(['id', 'created_at', 'updated_at']);
+ $clone->name = $name;
+ $clone->is_default = false;
+ $clone->save();
+
+ foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
+ SongArrangementGroup::create([
+ 'song_arrangement_id' => $clone->id,
+ 'song_group_id' => $group->song_group_id,
+ 'order' => $group->order,
+ ]);
+ }
+
+ return $clone->load('arrangementGroups');
+ });
+ }
+}
diff --git a/app/Services/TranslationService.php b/app/Services/TranslationService.php
new file mode 100644
index 0000000..cbfdb4a
--- /dev/null
+++ b/app/Services/TranslationService.php
@@ -0,0 +1,99 @@
+get($url);
+
+ if ($response->successful()) {
+ $html = $response->body();
+ $text = strip_tags($html);
+ $text = trim($text);
+
+ return $text !== '' ? $text : null;
+ }
+ } catch (\Exception) {
+ // Best-effort: Fehler stillschweigend behandeln
+ }
+
+ return null;
+ }
+
+ /**
+ * Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
+ *
+ * Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
+ * Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
+ *
+ * Beispiel:
+ * Slide 1 hat 4 Zeilen → bekommt die nächsten 4 Zeilen der Übersetzung
+ * Slide 2 hat 2 Zeilen → bekommt die nächsten 2 Zeilen
+ * Slide 3 hat 4 Zeilen → bekommt die nächsten 4 Zeilen
+ */
+ public function importTranslation(Song $song, string $text): void
+ {
+ $translatedLines = explode("\n", $text);
+ $offset = 0;
+
+ // Alle Gruppen nach order sortiert laden, mit Slides
+ $groups = $song->groups()->orderBy('order')->with([
+ 'slides' => fn ($query) => $query->orderBy('order'),
+ ])->get();
+
+ foreach ($groups as $group) {
+ foreach ($group->slides as $slide) {
+ $originalLineCount = count(explode("\n", $slide->text_content ?? ''));
+ $chunk = array_slice($translatedLines, $offset, $originalLineCount);
+ $offset += $originalLineCount;
+
+ $slide->update([
+ 'text_content_translated' => implode("\n", $chunk),
+ ]);
+ }
+ }
+
+ $this->markAsTranslated($song);
+ }
+
+ /**
+ * Song als "hat Übersetzung" markieren.
+ */
+ public function markAsTranslated(Song $song): void
+ {
+ $song->update(['has_translation' => true]);
+ }
+
+ /**
+ * Übersetzung eines Songs komplett entfernen.
+ *
+ * Löscht alle text_content_translated Felder und setzt has_translation auf false.
+ */
+ public function removeTranslation(Song $song): void
+ {
+ // Alle Slides des Songs über die Gruppen aktualisieren
+ $slideIds = SongSlide::whereIn(
+ 'song_group_id',
+ $song->groups()->pluck('id')
+ )->pluck('id');
+
+ SongSlide::whereIn('id', $slideIds)->update([
+ 'text_content_translated' => null,
+ ]);
+
+ $song->update(['has_translation' => false]);
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index b5cf3ce..007d534 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -7,6 +7,7 @@
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
+ api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
diff --git a/resources/js/Components/ArrangementConfigurator.vue b/resources/js/Components/ArrangementConfigurator.vue
new file mode 100644
index 0000000..cba7e10
--- /dev/null
+++ b/resources/js/Components/ArrangementConfigurator.vue
@@ -0,0 +1,296 @@
+
+
+
+
+ Verfügbare Gruppen
+
+
+
+ Gruppenfolge
+
+
+
Hier siehst du alle heutigen und kommenden Services.
+| Titel | +Prediger | +Beamer-Techniker | +Anzahl Songs | +Letzte Änderung | +Status | +Aktionen | +
|---|---|---|---|---|---|---|
|
+ {{ service.title }}
+ {{ formatDate(service.date) }}
+ |
+
+ + {{ service.preacher_name || '-' }} + | + ++ {{ service.beamer_tech_name || '-' }} + | + ++ {{ service.songs_total_count }} + | + ++ {{ formatDateTime(service.updated_at) }} + | + +
+
+
+
+ {{ service.songs_mapped_count }}/{{ service.songs_total_count }} Songs zugeordnet
+
+
+
+ {{ service.songs_arranged_count }}/{{ service.songs_total_count }} Arrangements geprueft
+
+
+
+
+ Predigtfolien
+
+
+
+
+ {{ service.info_slides_count }} Infofolien
+
+
+
+
+
+ Abgeschlossen am
+ {{ service.finalized_at ? formatDateTime(service.finalized_at) : '-' }}
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
Zeile 1
Zeile 2
', 200), + ]); + + $result = $this->service->fetchFromUrl('https://example.com/lyrics'); + + expect($result)->not->toBeNull(); + expect($result)->toContain('Zeile 1'); + expect($result)->toContain('Zeile 2'); + // HTML tags should be stripped + expect($result)->not->toContain(''); + expect($result)->not->toContain(''); +}); + +test('fetchFromUrl returns null on HTTP failure', function () { + Http::fake([ + 'https://example.com/broken' => Http::response('Not Found', 404), + ]); + + $result = $this->service->fetchFromUrl('https://example.com/broken'); + + expect($result)->toBeNull(); +}); + +test('fetchFromUrl returns null on connection error', function () { + Http::fake([ + 'https://timeout.example.com/*' => fn () => throw new \Illuminate\Http\Client\ConnectionException('Timeout'), + ]); + + $result = $this->service->fetchFromUrl('https://timeout.example.com/lyrics'); + + expect($result)->toBeNull(); +}); + +test('fetchFromUrl returns null for empty response body', function () { + Http::fake([ + 'https://example.com/empty' => Http::response('', 200), + ]); + + $result = $this->service->fetchFromUrl('https://example.com/empty'); + + expect($result)->toBeNull(); +}); + +// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) --- + +test('importTranslation distributes lines by slide line counts', function () { + $song = Song::factory()->create(['has_translation' => false]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Strophe 1', + 'order' => 1, + ]); + + // Slide 1: 4 lines + $slide1 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4", + ]); + + // Slide 2: 2 lines + $slide2 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 2, + 'text_content' => "Original 5\nOriginal 6", + ]); + + // Slide 3: 4 lines + $slide3 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 3, + 'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10", + ]); + + $translatedText = "Zeile 1\nZeile 2\nZeile 3\nZeile 4\nZeile 5\nZeile 6\nZeile 7\nZeile 8\nZeile 9\nZeile 10"; + + $this->service->importTranslation($song, $translatedText); + + $slide1->refresh(); + $slide2->refresh(); + $slide3->refresh(); + + // Slide 1 gets lines 1-4 + expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4"); + // Slide 2 gets lines 5-6 + expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6"); + // Slide 3 gets lines 7-10 + expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10"); +}); + +test('importTranslation distributes across multiple groups', function () { + $song = Song::factory()->create(['has_translation' => false]); + + $group1 = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Strophe 1', + 'order' => 1, + ]); + + $group2 = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Refrain', + 'order' => 2, + ]); + + $slide1 = SongSlide::factory()->create([ + 'song_group_id' => $group1->id, + 'order' => 1, + 'text_content' => "Line A\nLine B", + ]); + + $slide2 = SongSlide::factory()->create([ + 'song_group_id' => $group2->id, + 'order' => 1, + 'text_content' => "Line C\nLine D\nLine E", + ]); + + $translatedText = "Über A\nÜber B\nÜber C\nÜber D\nÜber E"; + + $this->service->importTranslation($song, $translatedText); + + $slide1->refresh(); + $slide2->refresh(); + + expect($slide1->text_content_translated)->toBe("Über A\nÜber B"); + expect($slide2->text_content_translated)->toBe("Über C\nÜber D\nÜber E"); +}); + +test('importTranslation handles fewer translation lines than original', function () { + $song = Song::factory()->create(['has_translation' => false]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'order' => 1, + ]); + + $slide1 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => "Line 1\nLine 2\nLine 3", + ]); + + $slide2 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 2, + 'text_content' => "Line 4\nLine 5", + ]); + + // Only 3 lines for 5 lines total + $translatedText = "Zeile 1\nZeile 2\nZeile 3"; + + $this->service->importTranslation($song, $translatedText); + + $slide1->refresh(); + $slide2->refresh(); + + // Slide 1 gets all 3 available lines + expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3"); + // Slide 2 gets empty (no lines left) + expect($slide2->text_content_translated)->toBe(''); +}); + +test('importTranslation marks song as translated', function () { + $song = Song::factory()->create(['has_translation' => false]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'order' => 1, + ]); + + SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => "Line 1", + ]); + + $this->service->importTranslation($song, "Zeile 1"); + + $song->refresh(); + expect($song->has_translation)->toBeTrue(); +}); + +// --- MARK AS TRANSLATED --- + +test('markAsTranslated sets has_translation to true', function () { + $song = Song::factory()->create(['has_translation' => false]); + + $this->service->markAsTranslated($song); + + $song->refresh(); + expect($song->has_translation)->toBeTrue(); +}); + +// --- REMOVE TRANSLATION --- + +test('removeTranslation clears all translated text and sets flag to false', function () { + $song = Song::factory()->create(['has_translation' => true]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'order' => 1, + ]); + + $slide1 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => "Original", + 'text_content_translated' => "Übersetzt", + ]); + + $slide2 = SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 2, + 'text_content' => "Original 2", + 'text_content_translated' => "Übersetzt 2", + ]); + + $this->service->removeTranslation($song); + + $song->refresh(); + $slide1->refresh(); + $slide2->refresh(); + + expect($song->has_translation)->toBeFalse(); + expect($slide1->text_content_translated)->toBeNull(); + expect($slide2->text_content_translated)->toBeNull(); +}); + +// --- CONTROLLER ENDPOINTS --- + +test('POST translation/fetch-url returns scraped text', function () { + Http::fake([ + 'https://lyrics.example.com/song' => Http::response('