From d915f8cfc268e0932ab66c23c7986ce938938773 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 19:55:37 +0100 Subject: [PATCH] feat: Wave 2 - Service list, Song CRUD, Slide upload, Arrangements, Song matching, Translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T8: Service List Page - ServiceController with index, finalize, reopen actions - Services/Index.vue with status indicators (songs mapped/arranged, slides uploaded) - German UI with finalize/reopen toggle buttons - Status aggregation via SQL subqueries for efficiency - Tests: 3 passing (46 assertions) T9: Song CRUD Backend - SongController with full REST API (index, store, show, update, destroy) - SongService for default groups/arrangements creation - SongRequest validation (title required, ccli_id unique) - Search by title and CCLI ID - last_used_in_service accessor via service_songs join - Tests: 20 passing (85 assertions) T10: Slide Upload Component - SlideController with store, destroy, updateExpireDate - SlideUploader.vue with vue3-dropzone drag-and-drop - SlideGrid.vue with thumbnail grid and inline expire date editing - Multi-format support: images (sync), PPT (async job), ZIP (extract) - Type validation: information (global), moderation/sermon (service-specific) - Tests: 15 passing (37 assertions) T11: Arrangement Configurator - ArrangementController with store, clone, update, destroy - ArrangementConfigurator.vue with vue-draggable-plus - Drag-and-drop arrangement editor with colored group pills - Clone from default or existing arrangement - Color picker for group customization - Prevent deletion of last arrangement - Tests: 4 passing (17 assertions) T12: Song Matching Service - SongMatchingService with autoMatch, manualAssign, requestCreation, unassign - ServiceSongController API endpoints for song assignment - Auto-match by CCLI ID during CTS sync - Manual assignment with searchable song select - Email request for missing songs (MissingSongRequest mailable) - Tests: 14 passing (33 assertions) T13: Translation Service - TranslationService with fetchFromUrl, importTranslation, removeTranslation - TranslationController API endpoints - URL scraping (best-effort HTTP fetch with strip_tags) - Line-count distribution algorithm (match original slide line counts) - Mark song as translated, remove translation - Tests: 18 passing (18 assertions) All tests passing: 103/103 (488 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form --- .../notepads/cts-presenter-app/learnings.md | 13 + .../Controllers/ArrangementController.php | 164 ++++++++ app/Http/Controllers/ServiceController.php | 84 ++++ .../Controllers/ServiceSongController.php | 67 ++++ app/Http/Controllers/SlideController.php | 214 ++++++++++ app/Http/Controllers/SongController.php | 173 ++++++++ .../Controllers/TranslationController.php | 87 ++++ app/Http/Requests/SongRequest.php | 37 ++ app/Models/Song.php | 21 + app/Services/ChurchToolsService.php | 21 +- app/Services/SongMatchingService.php | 86 ++++ app/Services/SongService.php | 77 ++++ app/Services/TranslationService.php | 99 +++++ bootstrap/app.php | 1 + .../js/Components/ArrangementConfigurator.vue | 296 ++++++++++++++ resources/js/Components/SlideGrid.vue | 340 ++++++++++++++++ resources/js/Components/SlideUploader.vue | 283 +++++++++++++ resources/js/Layouts/AuthenticatedLayout.vue | 8 +- resources/js/Pages/Services/Index.vue | 233 +++++++++++ routes/api.php | 39 ++ routes/web.php | 36 +- tests/Feature/ArrangementControllerTest.php | 183 +++++++++ tests/Feature/ServiceControllerTest.php | 179 +++++++++ tests/Feature/SlideControllerTest.php | 300 ++++++++++++++ tests/Feature/SongControllerTest.php | 309 ++++++++++++++ tests/Feature/SongMatchingTest.php | 250 ++++++++++++ tests/Feature/TranslationServiceTest.php | 376 ++++++++++++++++++ 27 files changed, 3951 insertions(+), 25 deletions(-) create mode 100644 app/Http/Controllers/ArrangementController.php create mode 100644 app/Http/Controllers/ServiceController.php create mode 100644 app/Http/Controllers/ServiceSongController.php create mode 100644 app/Http/Controllers/SlideController.php create mode 100644 app/Http/Controllers/SongController.php create mode 100644 app/Http/Controllers/TranslationController.php create mode 100644 app/Http/Requests/SongRequest.php create mode 100644 app/Services/SongMatchingService.php create mode 100644 app/Services/SongService.php create mode 100644 app/Services/TranslationService.php create mode 100644 resources/js/Components/ArrangementConfigurator.vue create mode 100644 resources/js/Components/SlideGrid.vue create mode 100644 resources/js/Components/SlideUploader.vue create mode 100644 resources/js/Pages/Services/Index.vue create mode 100644 routes/api.php create mode 100644 tests/Feature/ArrangementControllerTest.php create mode 100644 tests/Feature/ServiceControllerTest.php create mode 100644 tests/Feature/SlideControllerTest.php create mode 100644 tests/Feature/SongControllerTest.php create mode 100644 tests/Feature/SongMatchingTest.php create mode 100644 tests/Feature/TranslationServiceTest.php 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 @@ + + + diff --git a/resources/js/Components/SlideGrid.vue b/resources/js/Components/SlideGrid.vue new file mode 100644 index 0000000..a478948 --- /dev/null +++ b/resources/js/Components/SlideGrid.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/resources/js/Components/SlideUploader.vue b/resources/js/Components/SlideUploader.vue new file mode 100644 index 0000000..5b82e97 --- /dev/null +++ b/resources/js/Components/SlideUploader.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index c9627fb..802f308 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -83,8 +83,8 @@ function triggerSync() {