From ae42b48753abb576fcffe2062f81951ef0a5a33c Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 31 May 2026 14:45:47 +0200 Subject: [PATCH] feat(songs): per-song sections + section editing; fix CCLI import bugs Refactor lyric storage so each song owns its sections instead of sharing global labels. Adds song_sections (per song+label) owning song_slides; labels stay global ProPresenter group tags (name/color/macro). Arrangements now reference sections, so editing/importing one song no longer corrupts others that share a label name. - New: song_sections table + migration with safe backfill; SongSection, SongArrangementSection models; SongSectionController (edit/add/delete sections, immediate persistence) wired into SongEditModal. - Refactor writers/readers: CcliImport, ProImport, SongService, ArrangementController, SongController, ProExport, PDF, Translation (translation reset now section-scoped), CCLI pairing. - CCLI import fixes: parse SongSelect copy-icon format (German "Vers" abbrev + trailing author), fill empty CTS-synced songs instead of blocking as duplicate, distinct label colors per section kind, import&edit/existing-song open the edit modal (no 404/405), teleport paste dialog above assign dialog, preview shows section content, correct SongSelect search URL, copy-icon instructions. - Bookmarklet clicks #generalCopyLyricsButton and captures clipboard; serves correct host from request. - Export: embed key-visual/background under fixed bundle-relative names. - Tests updated for the section model; new section + isolation coverage. --- AGENTS.md | 11 + .../Controllers/ArrangementController.php | 86 ++- .../Controllers/BookmarkletController.php | 59 +- app/Http/Controllers/ServiceController.php | 31 +- app/Http/Controllers/SongController.php | 35 +- app/Http/Controllers/SongPdfController.php | 14 +- .../Controllers/SongSectionController.php | 174 ++++++ .../Controllers/TranslationController.php | 23 +- app/Models/Label.php | 10 +- app/Models/Song.php | 5 + app/Models/SongArrangement.php | 7 +- app/Models/SongArrangementLabel.php | 25 +- app/Models/SongArrangementSection.php | 30 + app/Models/SongSection.php | 41 ++ app/Models/SongSlide.php | 6 +- app/Services/CcliImportService.php | 66 ++- app/Services/CcliPasteParser.php | 26 + .../CcliTranslationPairingService.php | 11 +- app/Services/PlaylistExportService.php | 93 +++- app/Services/ProBundleExportService.php | 54 +- app/Services/ProExportService.php | 33 +- app/Services/ProImportService.php | 26 +- app/Services/ServiceImageResolver.php | 12 + app/Services/SongService.php | 37 +- app/Services/TranslationService.php | 20 +- app/Support/CcliLabels.php | 7 +- .../factories/SongArrangementLabelFactory.php | 4 +- .../SongArrangementSectionFactory.php | 22 + database/factories/SongSectionFactory.php | 22 + database/factories/SongSlideFactory.php | 4 +- ...reate_song_sections_and_rescope_slides.php | 246 +++++++++ package-lock.json | 520 ++++++++++-------- .../js/Components/ArrangementConfigurator.vue | 2 +- resources/js/Components/ArrangementDialog.vue | 13 +- resources/js/Components/CcliPasteDialog.vue | 37 +- resources/js/Components/SongEditModal.vue | 383 ++++++++++++- resources/js/Pages/Settings.vue | 2 +- .../js/Pages/Songs/ImportFromCcliPaste.vue | 11 + resources/js/Pages/Songs/Index.vue | 3 +- routes/web.php | 4 + tests/Feature/AgendaItemDownloadTest.php | 5 +- tests/Feature/ArrangementControllerTest.php | 35 +- tests/Feature/BookmarkletControllerTest.php | 78 ++- tests/Feature/CcliImportServiceTest.php | 34 ++ tests/Feature/CcliPasteParserTest.php | 22 + .../CcliTranslationPairingServiceTest.php | 10 +- tests/Feature/FullPlaylistExportTest.php | 16 +- tests/Feature/KeyVisualFallbackTest.php | 9 +- .../Migrations/SongsToLabelsRefactorTest.php | 13 +- tests/Feature/PlaylistExportTest.php | 10 +- tests/Feature/PlaylistSequenceTest.php | 18 +- tests/Feature/ProFileExportTest.php | 51 +- tests/Feature/ProFileImportTest.php | 4 +- tests/Feature/SongCcliMetadataTest.php | 11 +- tests/Feature/SongControllerTest.php | 19 +- tests/Feature/SongEditModalTest.php | 6 +- tests/Feature/SongPdfTest.php | 40 +- tests/Feature/SongSectionControllerTest.php | 191 +++++++ tests/Feature/SongsBlockTest.php | 6 +- tests/Feature/TranslatePageTest.php | 12 +- tests/Feature/TranslationServiceTest.php | 40 +- tests/Pest.php | 18 +- .../ccli/copy-icon-vers-author-trailing.txt | 23 + 63 files changed, 2255 insertions(+), 631 deletions(-) create mode 100644 app/Http/Controllers/SongSectionController.php create mode 100644 app/Models/SongArrangementSection.php create mode 100644 app/Models/SongSection.php mode change 100644 => 100755 database/factories/SongArrangementLabelFactory.php create mode 100644 database/factories/SongArrangementSectionFactory.php create mode 100644 database/factories/SongSectionFactory.php mode change 100644 => 100755 database/factories/SongSlideFactory.php create mode 100644 database/migrations/2026_05_31_120000_create_song_sections_and_rescope_slides.php create mode 100644 tests/Feature/SongSectionControllerTest.php create mode 100644 tests/fixtures/ccli/copy-icon-vers-author-trailing.txt diff --git a/AGENTS.md b/AGENTS.md index d6fc198..6367e7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,17 @@ ## KeyVisual & Background When a service is finalized, the resolved filenames are snapshotted into the per-service columns so the export is stable even if the global default changes later. +### Export naming contract (portable bundles) + +On export the key-visual and background images are **embedded into the archive** under fixed names and referenced **bundle-relative** inside the `.pro` file (never by absolute path), so exports are portable to the presenter PC: + +| Image | Embedded filename + `.pro` reference | +|-------|--------------------------------------| +| Key-visual | `KEY_VISUAL.jpg` | +| Background | `BACKGROUND.jpg` | + +The fixed names are defined as `ServiceImageResolver::KEY_VISUAL_EXPORT_NAME` / `ServiceImageResolver::BACKGROUND_EXPORT_NAME`. The `slideData['background']` array carries `'path' => ''` with `'bundleRelative' => true`; the image bytes are added to the archive's embedded/media files under that same name (deduplicated per archive). Applies to `.proplaylist` (`PlaylistExportService`) and `.probundle` (`ProBundleExportService`). The bare single-song `.pro` download has no service context and carries no background. + ### Key files | File | Purpose | diff --git a/app/Http/Controllers/ArrangementController.php b/app/Http/Controllers/ArrangementController.php index d9168b4..972b65b 100644 --- a/app/Http/Controllers/ArrangementController.php +++ b/app/Http/Controllers/ArrangementController.php @@ -5,9 +5,11 @@ use App\Models\Label; use App\Models\Song; use App\Models\SongArrangement; +use App\Models\SongSection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class ArrangementController extends Controller { @@ -29,18 +31,18 @@ public function store(Request $request, Song $song): RedirectResponse return; } - $arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get(); + $arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get(); - $rows = $arrangementLabels->values()->map(fn ($al, $index) => [ + $rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [ 'song_arrangement_id' => $arrangement->id, - 'label_id' => $al->label_id, + 'song_section_id' => $arrangementSection->song_section_id, 'order' => $index + 1, 'created_at' => now(), 'updated_at' => now(), ])->all(); if ($rows !== []) { - $arrangement->arrangementLabels()->insert($rows); + $arrangement->arrangementSections()->insert($rows); } }); @@ -54,7 +56,7 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR ]); DB::transaction(function () use ($arrangement, $data): void { - $arrangement->loadMissing('arrangementLabels'); + $arrangement->loadMissing('arrangementSections'); $clone = $arrangement->song->arrangements()->create([ 'name' => $data['name'], @@ -71,22 +73,23 @@ public function update(Request $request, SongArrangement $arrangement): Redirect { $data = $request->validate([ 'groups' => ['array'], - 'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'], + 'groups.*.section_id' => ['nullable', 'integer', 'exists:song_sections,id'], + 'groups.*.label_id' => ['nullable', 'integer', 'exists:labels,id'], 'groups.*.order' => ['required', 'integer', 'min:1'], 'group_colors' => ['sometimes', 'array'], 'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], ]); - $labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values(); + $sectionIds = $this->sectionIdsForGroups($arrangement, $data['groups'] ?? []); - DB::transaction(function () use ($arrangement, $labelIds, $data): void { - $arrangement->arrangementLabels()->delete(); + DB::transaction(function () use ($arrangement, $sectionIds, $data): void { + $arrangement->arrangementSections()->delete(); - $rows = $labelIds + $rows = $sectionIds ->values() - ->map(fn (int $labelId, int $index) => [ + ->map(fn (int $sectionId, int $index) => [ 'song_arrangement_id' => $arrangement->id, - 'label_id' => $labelId, + 'song_section_id' => $sectionId, 'order' => $index + 1, 'created_at' => now(), 'updated_at' => now(), @@ -94,12 +97,19 @@ public function update(Request $request, SongArrangement $arrangement): Redirect ->all(); if ($rows !== []) { - $arrangement->arrangementLabels()->insert($rows); + $arrangement->arrangementSections()->insert($rows); } if (! empty($data['group_colors'])) { - foreach ($data['group_colors'] as $labelId => $color) { - Label::whereKey((int) $labelId)->update(['color' => $color]); + $sections = SongSection::whereIn('id', collect(array_keys($data['group_colors']))->map(fn ($id) => (int) $id)) + ->get() + ->keyBy('id'); + + foreach ($data['group_colors'] as $id => $color) { + $section = $sections->get((int) $id); + $labelId = $section?->label_id ?? (int) $id; + + Label::whereKey($labelId)->update(['color' => $color]); } } }); @@ -136,22 +146,56 @@ private function cloneArrangementLabels(?SongArrangement $source, SongArrangemen return; } - $arrangementLabels = $source->arrangementLabels + $arrangementSections = $source->arrangementSections ->sortBy('order') ->values(); - $rows = $arrangementLabels - ->map(fn ($arrangementLabel) => [ + $rows = $arrangementSections + ->map(fn ($arrangementSection) => [ 'song_arrangement_id' => $target->id, - 'label_id' => $arrangementLabel->label_id, - 'order' => $arrangementLabel->order, + 'song_section_id' => $arrangementSection->song_section_id, + 'order' => $arrangementSection->order, 'created_at' => now(), 'updated_at' => now(), ]) ->all(); if ($rows !== []) { - $target->arrangementLabels()->insert($rows); + $target->arrangementSections()->insert($rows); } } + + private function sectionIdsForGroups(SongArrangement $arrangement, array $groups): \Illuminate\Support\Collection + { + $songId = $arrangement->song_id; + $sectionIds = collect($groups)->map(function (array $group) use ($songId) { + if (isset($group['section_id'])) { + $section = SongSection::find((int) $group['section_id']); + + if ($section === null || (int) $section->song_id !== (int) $songId) { + throw ValidationException::withMessages([ + 'groups' => 'Diese Sektion gehört nicht zu diesem Song.', + ]); + } + + return $section->id; + } + + if (isset($group['label_id'])) { + $section = SongSection::where('song_id', $songId) + ->where('label_id', (int) $group['label_id']) + ->first(); + + if ($section !== null) { + return $section->id; + } + } + + throw ValidationException::withMessages([ + 'groups' => 'Bitte wähle gültige Song-Sektionen aus.', + ]); + })->values(); + + return $sectionIds; + } } diff --git a/app/Http/Controllers/BookmarkletController.php b/app/Http/Controllers/BookmarkletController.php index 7349829..41cb428 100644 --- a/app/Http/Controllers/BookmarkletController.php +++ b/app/Http/Controllers/BookmarkletController.php @@ -2,14 +2,19 @@ namespace App\Http\Controllers; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Config; final class BookmarkletController extends Controller { - public function show(): Response + public function show(Request $request): Response { - $appUrl = rtrim((string) Config::get('app.url', ''), '/'); + $appUrl = rtrim($request->getSchemeAndHttpHost(), '/'); + + if ($appUrl === '') { + $appUrl = rtrim((string) Config::get('app.url', ''), '/'); + } $bookmarkletScript = <<<'BOOKMARKLET' (function(){ @@ -18,20 +23,42 @@ public function show(): Response alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).'); return; } - var title = (document.querySelector('h1, .song-title, [class*="title"]') || {}).innerText || document.title || ''; - var author = (document.querySelector('.song-authors, .song-artist, [class*="author"]') || {}).innerText || ''; - var bodyText = document.body ? document.body.innerText : ''; - var ccliMatch = bodyText.match(/CCLI[\s#-]*(\d+)/i); - var ccliId = ccliMatch ? ccliMatch[1] : ''; - var payload = { - title: title.trim(), - author: author.trim(), - ccliId: ccliId, - sourceUrl: location.href, - rawText: bodyText - }; - var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); - window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank'); + function send(text){ + var ccliMatch = (text || '').match(/CCLI[\s#-]*(\d+)/i); + var payload = { + title: '', + author: '', + ccliId: ccliMatch ? ccliMatch[1] : '', + sourceUrl: location.href, + rawText: text || '' + }; + var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); + window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank'); + } + var btn = document.querySelector('#generalCopyLyricsButton'); + if(!btn){ + alert('Kopier-Symbol nicht gefunden. Bitte öffne die Liedtext-Ansicht auf SongSelect und versuche es erneut.'); + return; + } + var captured = null; + function onCopy(e){ + try { captured = e.clipboardData.getData('text/plain'); } catch(err) {} + } + document.addEventListener('copy', onCopy, true); + btn.click(); + setTimeout(function(){ + document.removeEventListener('copy', onCopy, true); + if(captured && captured.trim()){ + send(captured); + return; + } + if(navigator.clipboard && navigator.clipboard.readText){ + navigator.clipboard.readText().then(function(text){ send(text); }) + .catch(function(){ alert('Liedtext konnte nicht aus der Zwischenablage gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".'); }); + } else { + alert('Liedtext konnte nicht gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".'); + } + }, 250); })(); BOOKMARKLET; diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index e06e61f..6c3a404 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -131,13 +131,14 @@ public function edit(Service $service): Response $service->load([ 'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs.song', - 'serviceSongs.song.arrangements.arrangementLabels.label', + 'serviceSongs.song.arrangements.arrangementSections.section.label', 'serviceSongs.arrangement', 'slides', 'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems.slides', - 'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides', - 'agendaItems.serviceSong.arrangement.arrangementLabels.label', + 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides', + 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label', + 'agendaItems.serviceSong.arrangement.arrangementSections.section.label', ]); $songsCatalog = Song::query() @@ -295,13 +296,14 @@ public function edit(Service $service): Response 'id' => $arrangement->id, 'name' => $arrangement->name, 'is_default' => $arrangement->is_default, - 'groups' => $arrangement->arrangementLabels + 'groups' => $arrangement->arrangementSections ->sortBy('order') ->values() - ->map(fn ($arrangementLabel) => [ - 'id' => $arrangementLabel->label?->id, - 'name' => $arrangementLabel->label?->name, - 'color' => $arrangementLabel->label?->color, + ->map(fn ($arrangementSection) => [ + 'id' => $arrangementSection->section?->label?->id, + 'section_id' => $arrangementSection->section?->id, + 'name' => $arrangementSection->section?->label?->name, + 'color' => $arrangementSection->section?->label?->color, ]) ->filter(fn ($group) => $group['id'] !== null) ->values(), @@ -474,14 +476,15 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection return collect(); } - return $defaultArr->arrangementLabels + return $defaultArr->arrangementSections ->sortBy('order') ->values() - ->map(fn ($arrangementLabel) => [ - 'id' => $arrangementLabel->label?->id, - 'name' => $arrangementLabel->label?->name, - 'color' => $arrangementLabel->label?->color, - 'order' => $arrangementLabel->order, + ->map(fn ($arrangementSection) => [ + 'id' => $arrangementSection->section?->label?->id, + 'section_id' => $arrangementSection->section?->id, + 'name' => $arrangementSection->section?->label?->name, + 'color' => $arrangementSection->section?->label?->color, + 'order' => $arrangementSection->order, ]) ->filter(fn ($group) => $group['id'] !== null) ->values(); diff --git a/app/Http/Controllers/SongController.php b/app/Http/Controllers/SongController.php index a4c81f1..5ab4f8e 100644 --- a/app/Http/Controllers/SongController.php +++ b/app/Http/Controllers/SongController.php @@ -62,13 +62,13 @@ public function store(SongRequest $request): JsonResponse return response()->json([ 'message' => 'Song erfolgreich erstellt', - 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])), + 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])), ], 201); } public function show(int $id): JsonResponse { - $song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id); + $song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id); if (! $song) { return response()->json(['message' => 'Song nicht gefunden'], 404); @@ -91,7 +91,7 @@ public function update(SongRequest $request, int $id): JsonResponse return response()->json([ 'message' => 'Song erfolgreich aktualisiert', - 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])), + 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])), ]); } @@ -110,22 +110,24 @@ public function destroy(int $id): JsonResponse ]); } - private function formatSongDetail(Song $song): array + public function formatSongDetail(Song $song): array { $defaultArr = $song->arrangements->firstWhere('is_default', true); $groupsPayload = []; if ($defaultArr !== null) { - $groupsPayload = $defaultArr->arrangementLabels + $groupsPayload = $defaultArr->arrangementSections ->sortBy('order') ->values() - ->map(fn ($al) => [ - 'id' => $al->label?->id, - 'name' => $al->label?->name, - 'color' => $al->label?->color, - 'order' => $al->order, - 'slides' => $al->label - ? $al->label->songSlides + ->map(fn ($arrangementSection) => [ + 'id' => $arrangementSection->section?->id, + 'section_id' => $arrangementSection->section?->id, + 'label_id' => $arrangementSection->section?->label_id, + 'name' => $arrangementSection->section?->label?->name, + 'color' => $arrangementSection->section?->label?->color, + 'order' => $arrangementSection->order, + 'slides' => $arrangementSection->section + ? $arrangementSection->section->slides ->sortBy('order') ->values() ->map(fn ($slide) => [ @@ -157,10 +159,11 @@ private function formatSongDetail(Song $song): array 'id' => $arr->id, 'name' => $arr->name, 'is_default' => $arr->is_default, - 'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [ - 'id' => $al->id, - 'label_id' => $al->label_id, - 'order' => $al->order, + 'arrangement_groups' => $arr->arrangementSections->sortBy('order')->values()->map(fn ($arrangementSection) => [ + 'id' => $arrangementSection->id, + 'section_id' => $arrangementSection->song_section_id, + 'label_id' => $arrangementSection->section?->label_id, + 'order' => $arrangementSection->order, ])->toArray(), ])->toArray(), ]; diff --git a/app/Http/Controllers/SongPdfController.php b/app/Http/Controllers/SongPdfController.php index 8aed81a..8bd89b6 100644 --- a/app/Http/Controllers/SongPdfController.php +++ b/app/Http/Controllers/SongPdfController.php @@ -57,21 +57,23 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso private function buildGroupsInOrder(SongArrangement $arrangement): array { $arrangement->load([ - 'arrangementLabels' => fn ($query) => $query->orderBy('order'), - 'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'), + 'arrangementSections' => fn ($query) => $query->orderBy('order'), + 'arrangementSections.section.slides' => fn ($query) => $query->orderBy('order'), + 'arrangementSections.section.label', ]); - return $arrangement->arrangementLabels->map(function ($arrangementLabel) { - $label = $arrangementLabel->label; + return $arrangement->arrangementSections->map(function ($arrangementSection) { + $section = $arrangementSection->section; + $label = $section?->label; - if ($label === null) { + if ($section === null || $label === null) { return null; } return [ 'name' => $label->name, 'color' => $label->color ?? '#6b7280', - 'slides' => $label->songSlides->map(fn ($slide) => [ + 'slides' => $section->slides->map(fn ($slide) => [ 'text_content' => $slide->text_content, 'text_content_translated' => $slide->text_content_translated, ])->values()->all(), diff --git a/app/Http/Controllers/SongSectionController.php b/app/Http/Controllers/SongSectionController.php new file mode 100644 index 0000000..bd0931c --- /dev/null +++ b/app/Http/Controllers/SongSectionController.php @@ -0,0 +1,174 @@ +validate([ + 'label_name' => ['required', 'string', 'max:255'], + 'color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'slides' => ['array'], + 'slides.*.text_content' => ['required', 'string'], + 'slides.*.text_content_translated' => ['nullable', 'string'], + ], $this->validationMessages()); + + $normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']); + + $responseSong = DB::transaction(function () use ($song, $data, $normalizedLabelName): Song { + $label = Label::firstOrCreate( + ['name' => $normalizedLabelName], + ['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR], + ); + + if ($song->sections()->where('label_id', $label->id)->exists()) { + abort(response()->json([ + 'message' => 'Dieser Abschnitt existiert bereits in diesem Lied.', + ], 422)); + } + + $section = $song->sections()->create([ + 'label_id' => $label->id, + 'order' => ((int) $song->sections()->max('order')) + 1, + ]); + + $this->replaceSlides($section, $data['slides'] ?? []); + + $defaultArrangement = $song->arrangements()->firstOrCreate( + ['is_default' => true], + ['name' => 'Normal'], + ); + + $defaultArrangement->arrangementSections()->create([ + 'song_section_id' => $section->id, + 'order' => ((int) $defaultArrangement->arrangementSections()->max('order')) + 1, + ]); + + $this->recomputeHasTranslation($song); + + return $this->freshSong($song); + }); + + return response()->json([ + 'message' => 'Sektion wurde hinzugefügt.', + 'data' => $this->songController->formatSongDetail($responseSong), + ], 201); + } + + public function update(Request $request, Song $song, SongSection $section): JsonResponse + { + if ((int) $section->song_id !== (int) $song->id) { + return response()->json(['message' => 'Sektion nicht gefunden.'], 404); + } + + $data = $request->validate([ + 'slides' => ['required', 'array'], + 'slides.*.text_content' => ['required', 'string'], + 'slides.*.text_content_translated' => ['nullable', 'string'], + 'order' => ['sometimes', 'integer'], + ], $this->validationMessages()); + + $responseSong = DB::transaction(function () use ($song, $section, $data): Song { + if (array_key_exists('order', $data)) { + $section->update(['order' => $data['order']]); + } + + $this->replaceSlides($section, $data['slides']); + $this->recomputeHasTranslation($song); + + return $this->freshSong($song); + }); + + return response()->json([ + 'message' => 'Sektion wurde gespeichert.', + 'data' => $this->songController->formatSongDetail($responseSong), + ]); + } + + public function destroy(Song $song, SongSection $section): JsonResponse + { + if ((int) $section->song_id !== (int) $song->id) { + return response()->json(['message' => 'Sektion nicht gefunden.'], 404); + } + + $responseSong = DB::transaction(function () use ($song, $section): Song { + SongArrangementSection::query() + ->where('song_section_id', $section->id) + ->whereHas('arrangement', fn ($query) => $query->where('song_id', $song->id)) + ->delete(); + + $section->slides()->delete(); + $section->delete(); + + $this->recomputeHasTranslation($song); + + return $this->freshSong($song); + }); + + return response()->json([ + 'message' => 'Sektion wurde gelöscht.', + 'data' => $this->songController->formatSongDetail($responseSong), + ]); + } + + private function replaceSlides(SongSection $section, array $slides): void + { + $section->slides()->delete(); + + foreach (array_values($slides) as $index => $slide) { + $section->slides()->create([ + 'order' => $index + 1, + 'text_content' => $slide['text_content'], + 'text_content_translated' => $slide['text_content_translated'] ?? null, + ]); + } + } + + private function recomputeHasTranslation(Song $song): void + { + $hasTranslation = $song->sections() + ->whereHas('slides', fn ($query) => $query + ->whereNotNull('text_content_translated') + ->where('text_content_translated', '!=', '')) + ->exists(); + + $song->update(['has_translation' => $hasTranslation]); + } + + private function freshSong(Song $song): Song + { + return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); + } + + private function validationMessages(): array + { + return [ + 'label_name.required' => 'Bitte gib einen Namen für die Sektion ein.', + 'label_name.string' => 'Der Sektionsname muss ein Text sein.', + 'label_name.max' => 'Der Sektionsname darf höchstens 255 Zeichen lang sein.', + 'color.regex' => 'Bitte gib eine gültige Hex-Farbe an.', + 'slides.required' => 'Bitte gib mindestens eine Folie an.', + 'slides.array' => 'Die Folien müssen als Liste gesendet werden.', + 'slides.*.text_content.required' => 'Bitte gib einen Text für jede Folie ein.', + 'slides.*.text_content.string' => 'Der Folientext muss ein Text sein.', + 'slides.*.text_content_translated.string' => 'Der übersetzte Folientext muss ein Text sein.', + 'order.integer' => 'Die Reihenfolge muss eine Zahl sein.', + ]; + } +} diff --git a/app/Http/Controllers/TranslationController.php b/app/Http/Controllers/TranslationController.php index 787d049..02a95d1 100644 --- a/app/Http/Controllers/TranslationController.php +++ b/app/Http/Controllers/TranslationController.php @@ -19,24 +19,27 @@ public function page(Song $song): Response { $song->load([ 'arrangements' => fn ($q) => $q->where('is_default', true), - 'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'), - 'arrangements.arrangementLabels.label.songSlides', + 'arrangements.arrangementSections' => fn ($q) => $q->orderBy('order'), + 'arrangements.arrangementSections.section.slides', + 'arrangements.arrangementSections.section.label', ]); $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); $groups = collect(); if ($defaultArr !== null) { - $groups = $defaultArr->arrangementLabels + $groups = $defaultArr->arrangementSections ->sortBy('order') ->values() - ->map(fn ($al) => [ - 'id' => $al->label?->id, - 'name' => $al->label?->name, - 'color' => $al->label?->color, - 'order' => $al->order, - 'slides' => $al->label - ? $al->label->songSlides + ->map(fn ($arrangementSection) => [ + 'id' => $arrangementSection->section?->id, + 'section_id' => $arrangementSection->section?->id, + 'label_id' => $arrangementSection->section?->label_id, + 'name' => $arrangementSection->section?->label?->name, + 'color' => $arrangementSection->section?->label?->color, + 'order' => $arrangementSection->order, + 'slides' => $arrangementSection->section + ? $arrangementSection->section->slides ->sortBy('order') ->values() ->map(fn ($slide) => [ diff --git a/app/Models/Label.php b/app/Models/Label.php index e28ae87..eae4170 100644 --- a/app/Models/Label.php +++ b/app/Models/Label.php @@ -25,16 +25,16 @@ protected function casts(): array ]; } - public function songSlides(): HasMany - { - return $this->hasMany(SongSlide::class); - } - public function macroAssignments(): HasMany { return $this->hasMany(MacroAssignment::class); } + public function sections(): HasMany + { + return $this->hasMany(SongSection::class); + } + public function isHidden(): bool { return $this->hidden_at !== null; diff --git a/app/Models/Song.php b/app/Models/Song.php index a532b5b..1c25fd9 100644 --- a/app/Models/Song.php +++ b/app/Models/Song.php @@ -41,6 +41,11 @@ public function arrangements(): HasMany return $this->hasMany(SongArrangement::class); } + public function sections(): HasMany + { + return $this->hasMany(SongSection::class)->orderBy('order'); + } + public function serviceSongs(): HasMany { return $this->hasMany(ServiceSong::class); diff --git a/app/Models/SongArrangement.php b/app/Models/SongArrangement.php index 6dd55b6..1770a28 100644 --- a/app/Models/SongArrangement.php +++ b/app/Models/SongArrangement.php @@ -31,7 +31,12 @@ public function song(): BelongsTo public function arrangementLabels(): HasMany { - return $this->hasMany(SongArrangementLabel::class)->orderBy('order'); + return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order'); + } + + public function arrangementSections(): HasMany + { + return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order'); } public function serviceSongs(): HasMany diff --git a/app/Models/SongArrangementLabel.php b/app/Models/SongArrangementLabel.php index d3b9f7f..bf016a3 100644 --- a/app/Models/SongArrangementLabel.php +++ b/app/Models/SongArrangementLabel.php @@ -2,27 +2,4 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; - -class SongArrangementLabel extends Model -{ - use HasFactory; - - protected $fillable = [ - 'song_arrangement_id', - 'label_id', - 'order', - ]; - - public function arrangement(): BelongsTo - { - return $this->belongsTo(SongArrangement::class, 'song_arrangement_id'); - } - - public function label(): BelongsTo - { - return $this->belongsTo(Label::class); - } -} +class SongArrangementLabel extends SongArrangementSection {} diff --git a/app/Models/SongArrangementSection.php b/app/Models/SongArrangementSection.php new file mode 100644 index 0000000..2320c81 --- /dev/null +++ b/app/Models/SongArrangementSection.php @@ -0,0 +1,30 @@ +belongsTo(SongArrangement::class, 'song_arrangement_id'); + } + + public function section(): BelongsTo + { + return $this->belongsTo(SongSection::class, 'song_section_id'); + } +} diff --git a/app/Models/SongSection.php b/app/Models/SongSection.php new file mode 100644 index 0000000..84eceda --- /dev/null +++ b/app/Models/SongSection.php @@ -0,0 +1,41 @@ + 'integer', + ]; + } + + public function song(): BelongsTo + { + return $this->belongsTo(Song::class); + } + + public function label(): BelongsTo + { + return $this->belongsTo(Label::class); + } + + public function slides(): HasMany + { + return $this->hasMany(SongSlide::class, 'song_section_id')->orderBy('order'); + } +} diff --git a/app/Models/SongSlide.php b/app/Models/SongSlide.php index 05dd849..953b6eb 100644 --- a/app/Models/SongSlide.php +++ b/app/Models/SongSlide.php @@ -11,15 +11,15 @@ class SongSlide extends Model use HasFactory; protected $fillable = [ - 'label_id', + 'song_section_id', 'order', 'text_content', 'text_content_translated', 'notes', ]; - public function label(): BelongsTo + public function section(): BelongsTo { - return $this->belongsTo(Label::class); + return $this->belongsTo(SongSection::class, 'song_section_id'); } } diff --git a/app/Services/CcliImportService.php b/app/Services/CcliImportService.php index 4675882..e2b94ab 100644 --- a/app/Services/CcliImportService.php +++ b/app/Services/CcliImportService.php @@ -9,7 +9,7 @@ use App\Models\Song; use App\Models\SongArrangement; use App\Models\SongArrangementLabel; -use App\Models\SongSlide; +use App\Models\SongSection; use App\Services\DTO\ParsedCcliSection; use App\Services\DTO\ParsedCcliSong; use App\Support\CcliLabels; @@ -18,7 +18,18 @@ final class CcliImportService { - private const DEFAULT_LABEL_COLOR = '#3B82F6'; + private const LABEL_KIND_COLORS = [ + 'Verse' => '#3B82F6', + 'Chorus' => '#10B981', + 'Bridge' => '#F59E0B', + 'Pre-Chorus' => '#8B5CF6', + 'Tag' => '#EC4899', + 'Ending' => '#EF4444', + 'Intro' => '#14B8A6', + 'Interlude' => '#6366F1', + 'Outro' => '#F97316', + 'Misc' => '#64748B', + ]; public function __construct( private readonly CcliPasteParser $parser, @@ -37,7 +48,7 @@ public function import(string $rawText, ?string $sourceUrl = null): array $song = Song::withTrashed()->where('ccli_id', $parsed->ccliId)->first(); $status = 'created'; - if ($song !== null && ! $song->trashed()) { + if ($song !== null && ! $song->trashed() && $this->songHasContent($song)) { throw new DuplicateCcliSongException($song->id); } @@ -58,22 +69,26 @@ public function import(string $rawText, ?string $sourceUrl = null): array $warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.'; } - $labelIds = []; + $sectionIds = []; $hasTranslation = false; - foreach ($parsed->sections as $section) { - $label = $this->resolveLabel($section); - $labelIds[] = $label->id; + foreach ($parsed->sections as $order => $parsedSection) { + $label = $this->resolveLabel($parsedSection); + $section = SongSection::firstOrCreate( + ['song_id' => $song->id, 'label_id' => $label->id], + ['order' => $order + 1], + ); + $section->update(['order' => $order + 1]); + $sectionIds[] = $section->id; - $label->songSlides()->delete(); + $section->slides()->delete(); - foreach ($section->lines as $order => $line) { - $translatedLine = $section->linesTranslated[$order] ?? null; + foreach ($parsedSection->lines as $slideOrder => $line) { + $translatedLine = $parsedSection->linesTranslated[$slideOrder] ?? null; $hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== ''); - SongSlide::create([ - 'label_id' => $label->id, - 'order' => $order + 1, + $section->slides()->create([ + 'order' => $slideOrder + 1, 'text_content' => $line, 'text_content_translated' => $translatedLine, ]); @@ -93,15 +108,15 @@ public function import(string $rawText, ?string $sourceUrl = null): array SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete(); - foreach ($labelIds as $order => $labelId) { + foreach ($sectionIds as $order => $sectionId) { SongArrangementLabel::create([ 'song_arrangement_id' => $arrangement->id, - 'label_id' => $labelId, + 'song_section_id' => $sectionId, 'order' => $order + 1, ]); } - $song = $song->fresh(['arrangements.arrangementLabels.label.songSlides']); + $song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); ApiRequestLog::create([ 'method' => 'import', @@ -137,15 +152,32 @@ private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $s return Song::create(array_merge($songData, ['ccli_id' => $parsed->ccliId])); } + private function songHasContent(Song $song): bool + { + return $song->sections()->whereHas('slides')->exists(); + } + private function resolveLabel(ParsedCcliSection $section): Label { + $canonicalKind = CcliLabels::normalizeLabelName($section->kind); $canonicalLabelName = CcliLabels::normalizeLabelName( $section->kind.($section->number ? ' '.$section->number : ''), ); return Label::firstOrCreate( ['name' => $canonicalLabelName], - ['color' => self::DEFAULT_LABEL_COLOR, 'last_imported_at' => now()], + ['color' => $this->labelColor($canonicalKind), 'last_imported_at' => now()], ); } + + private function labelColor(string $canonicalKind): string + { + if (array_key_exists($canonicalKind, self::LABEL_KIND_COLORS)) { + return self::LABEL_KIND_COLORS[$canonicalKind]; + } + + $colors = array_values(self::LABEL_KIND_COLORS); + + return $colors[crc32($canonicalKind) % count($colors)]; + } } diff --git a/app/Services/CcliPasteParser.php b/app/Services/CcliPasteParser.php index 219b9cc..c1240c2 100644 --- a/app/Services/CcliPasteParser.php +++ b/app/Services/CcliPasteParser.php @@ -53,13 +53,30 @@ public function parse(string $rawText): ParsedCcliSong $copyrightText = null; $sections = []; $current = null; + $previousLineWasBlank = false; + $currentParagraphLineCount = 0; + $currentParagraphStartedAfterBlank = false; foreach (array_slice($lines, $firstSectionIndex) as $line) { if ($line === '') { + $previousLineWasBlank = true; + $currentParagraphLineCount = 0; + $currentParagraphStartedAfterBlank = false; + continue; } if ($isMetadataLine($line)) { + if ($author === null + && $current !== null + && $currentParagraphLineCount === 1 + && $currentParagraphStartedAfterBlank + ) { + $author = array_pop($current['lines']); + $currentParagraphLineCount = 0; + $currentParagraphStartedAfterBlank = false; + } + $extractedCcliId = CcliLabels::extractCcliId($line); if ($extractedCcliId !== null) { $ccliId = $extractedCcliId; @@ -94,12 +111,21 @@ public function parse(string $rawText): ParsedCcliSong 'modifier' => $label['modifier'], 'lines' => [], ]; + $previousLineWasBlank = false; + $currentParagraphLineCount = 0; + $currentParagraphStartedAfterBlank = false; continue; } if ($current !== null) { + if ($currentParagraphLineCount === 0) { + $currentParagraphStartedAfterBlank = $previousLineWasBlank; + } + $current['lines'][] = $line; + $currentParagraphLineCount++; + $previousLineWasBlank = false; } } diff --git a/app/Services/CcliTranslationPairingService.php b/app/Services/CcliTranslationPairingService.php index dd07e60..455536e 100644 --- a/app/Services/CcliTranslationPairingService.php +++ b/app/Services/CcliTranslationPairingService.php @@ -29,7 +29,7 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa { $parsed = $this->parser->parse($ccliRawText); - $localSong->loadMissing(['arrangements.arrangementLabels.label.songSlides']); + $localSong->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); $arrangement = $this->findArrangement($localSong, $arrangementName); @@ -47,16 +47,17 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa $unmatchedLabels = []; $allDistributedLines = []; - foreach ($arrangement->arrangementLabels->sortBy('order') as $arrangementLabel) { - $label = $arrangementLabel->label; + foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) { + $section = $arrangementSection->section; + $label = $section?->label; - if ($label === null) { + if ($section === null || $label === null) { continue; } $localCanonical = $this->canonicalLabel($label->name, null); $matchedSection = $ccliByCanonical[$localCanonical] ?? null; - $slides = $label->songSlides->sortBy('order')->values(); + $slides = $section->slides->sortBy('order')->values(); if ($matchedSection === null) { $unmatchedLabels[] = $label->name; diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 58ac39e..fb59e89 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -21,8 +21,9 @@ public function generatePlaylist(Service $service): array ->orderBy('sort_order') ->with([ 'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), - 'serviceSong.song.arrangements.arrangementLabels.label.songSlides', - 'serviceSong.arrangement.arrangementLabels.label', + 'serviceSong.song.arrangements.arrangementSections.section.slides', + 'serviceSong.song.arrangements.arrangementSections.section.label', + 'serviceSong.arrangement.arrangementSections.section.label', ]) ->get(); @@ -111,6 +112,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda rename($proPath, $destPath); $embeddedFiles[$proFilename] = file_get_contents($destPath); + $this->embedBackground($service, $embeddedFiles); $playlistItems[] = [ 'type' => 'presentation', @@ -201,12 +203,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda */ private function generatePlaylistLegacy(Service $service): array { - $service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides'); + $service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label'); $matchedSongs = $service->serviceSongs() ->whereNotNull('song_id') ->orderBy('order') - ->with('song.arrangements.arrangementLabels.label.songSlides') + ->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label') ->get(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); @@ -243,6 +245,7 @@ private function generatePlaylistLegacy(Service $service): array rename($proPath, $destPath); $embeddedFiles[$proFilename] = file_get_contents($destPath); + $this->embedBackground($service, $embeddedFiles); $playlistItems[] = [ 'type' => 'presentation', @@ -302,6 +305,7 @@ private function addSlidesFromCollection( $slideDataList = []; $imageFiles = []; $background = $this->backgroundData($service); + $backgroundAttached = false; foreach ($slides->values() as $index => $slide) { $storedPath = Storage::disk('public')->path($slide->stored_filename); @@ -324,11 +328,16 @@ private function addSlidesFromCollection( if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { $singleSlideData['background'] = $background; + $backgroundAttached = true; } $slideDataList[] = $singleSlideData; } + if ($backgroundAttached) { + $this->embedBackground($service, $embeddedFiles); + } + if (empty($slideDataList)) { return; } @@ -425,6 +434,8 @@ private function addKeyVisualFallbackPresentation( return; } + $this->embedKeyVisual($service, $embeddedFiles); + $label = $item->title ?: 'Keyvisual'; $groups = [ [ @@ -492,6 +503,8 @@ private function addKeyVisualSlide(Service $service, string $tempDir, array &$pl return; } + $this->embedKeyVisual($service, $embeddedFiles); + $slideData = ['imageOnly' => true, 'background' => $kvData]; $this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles); } @@ -531,36 +544,92 @@ private function backgroundData(?Service $service): ?array return null; } - $background = app(ServiceImageResolver::class)->backgroundFor($service); - - if ($background === null) { + if ($this->backgroundSourcePath($service) === null) { return null; } return [ - 'path' => Storage::disk('public')->path($background), + 'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, + 'bundleRelative' => true, ]; } private function keyVisualData(Service $service): ?array { - $keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service); - - if ($keyVisual === null) { + if ($this->keyVisualSourcePath($service) === null) { return null; } return [ - 'path' => Storage::disk('public')->path($keyVisual), + 'path' => ServiceImageResolver::KEY_VISUAL_EXPORT_NAME, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, + 'bundleRelative' => true, ]; } + /** Absolute filesystem path of the resolved background image, or null. */ + private function backgroundSourcePath(?Service $service): ?string + { + if ($service === null) { + return null; + } + + $background = app(ServiceImageResolver::class)->backgroundFor($service); + + if ($background === null || ! Storage::disk('public')->exists($background)) { + return null; + } + + return Storage::disk('public')->path($background); + } + + /** Absolute filesystem path of the resolved key-visual image, or null. */ + private function keyVisualSourcePath(Service $service): ?string + { + $keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service); + + if ($keyVisual === null || ! Storage::disk('public')->exists($keyVisual)) { + return null; + } + + return Storage::disk('public')->path($keyVisual); + } + + /** Embed the resolved background image bytes into the archive under the fixed export name. */ + private function embedBackground(?Service $service, array &$embeddedFiles): void + { + $sourcePath = $this->backgroundSourcePath($service); + + if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) { + return; + } + + $contents = @file_get_contents($sourcePath); + if ($contents !== false) { + $embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents; + } + } + + /** Embed the resolved key-visual image bytes into the archive under the fixed export name. */ + private function embedKeyVisual(Service $service, array &$embeddedFiles): void + { + $sourcePath = $this->keyVisualSourcePath($service); + + if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME])) { + return; + } + + $contents = @file_get_contents($sourcePath); + if ($contents !== false) { + $embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME] = $contents; + } + } + private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool { if ($background === null) { diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index 4bc7ac8..eaa8816 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -43,7 +43,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string $agendaItem->loadMissing([ 'service', 'slides', - 'serviceSong.song.arrangements.arrangementLabels.label.songSlides', + 'serviceSong.song.arrangements.arrangementSections.section.slides', + 'serviceSong.song.arrangements.arrangementSections.section.label', ]); $title = $agendaItem->title ?: 'Ablauf-Element'; @@ -63,7 +64,10 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string $parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service); $proFilename = self::safeFilename($song->title).'.pro'; - $bundle = new PresentationBundle($parserSong, $proFilename); + $songMediaFiles = []; + $this->embedBackground($agendaItem->service, $songMediaFiles); + + $bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles); $bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle'; ProBundleWriter::write($bundle, $bundlePath); @@ -95,6 +99,7 @@ private function buildBundleFromSlides( $slideData = []; $mediaFiles = []; $background = $this->backgroundData($service); + $backgroundAttached = false; foreach ($slides as $slide) { $sourcePath = Storage::disk('public')->path($slide->stored_filename); @@ -118,6 +123,7 @@ private function buildBundleFromSlides( if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { $singleSlideData['background'] = $background; + $backgroundAttached = true; } if ($service !== null && $partType !== null) { @@ -138,6 +144,10 @@ private function buildBundleFromSlides( $slideData[] = $singleSlideData; } + if ($backgroundAttached) { + $this->embedBackground($service, $mediaFiles); + } + $groups = [ [ 'name' => $groupName, @@ -164,6 +174,22 @@ private function buildBundleFromSlides( } private function backgroundData(?Service $service): ?array + { + if ($this->backgroundSourcePath($service) === null) { + return null; + } + + return [ + 'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME, + 'format' => 'JPG', + 'width' => 1920, + 'height' => 1080, + 'bundleRelative' => true, + ]; + } + + /** Absolute filesystem path of the resolved background image, or null. */ + private function backgroundSourcePath(?Service $service): ?string { if ($service === null) { return null; @@ -171,16 +197,26 @@ private function backgroundData(?Service $service): ?array $background = $this->imageResolver->backgroundFor($service); - if ($background === null) { + if ($background === null || ! Storage::disk('public')->exists($background)) { return null; } - return [ - 'path' => Storage::disk('public')->path($background), - 'format' => 'JPG', - 'width' => 1920, - 'height' => 1080, - ]; + return Storage::disk('public')->path($background); + } + + /** Embed the resolved background image bytes into the bundle under the fixed export name. */ + private function embedBackground(?Service $service, array &$mediaFiles): void + { + $sourcePath = $this->backgroundSourcePath($service); + + if ($sourcePath === null || isset($mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) { + return; + } + + $contents = @file_get_contents($sourcePath); + if ($contents !== false) { + $mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents; + } } private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php index 825d135..814e608 100644 --- a/app/Services/ProExportService.php +++ b/app/Services/ProExportService.php @@ -4,7 +4,6 @@ use App\Models\Service; use App\Models\Song; -use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProFileGenerator; class ProExportService @@ -31,7 +30,7 @@ public function generateProFile(Song $song, ?Service $service = null): string public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song { - $song->loadMissing(['arrangements.arrangementLabels.label.songSlides']); + $song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); return ProFileGenerator::generate( $song->title, @@ -49,29 +48,30 @@ private function buildGroups(Song $song, ?Service $service = null): array return []; } - $defaultArr->loadMissing('arrangementLabels.label.songSlides'); + $defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label'); $groups = []; - $seenLabelIds = []; + $seenSectionIds = []; $background = $this->backgroundData($service); - foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) { - $label = $arrangementLabel->label; + foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { + $section = $arrangementSection->section; + $label = $section?->label; - if ($label === null) { + if ($section === null || $label === null) { continue; } - if (in_array($label->id, $seenLabelIds, true)) { + if (in_array($section->id, $seenSectionIds, true)) { continue; } - $seenLabelIds[] = $label->id; + $seenSectionIds[] = $section->id; $slides = []; - $labelSlides = $label->songSlides->sortBy('order')->values(); - $totalSlides = $labelSlides->count(); + $sectionSlides = $section->slides->sortBy('order')->values(); + $totalSlides = $sectionSlides->count(); - foreach ($labelSlides as $slideIndex => $slide) { + foreach ($sectionSlides as $slideIndex => $slide) { $slideData = ['text' => $slide->text_content ?? '']; if ($slide->text_content_translated) { @@ -121,10 +121,11 @@ private function backgroundData(?Service $service): ?array } return [ - 'path' => Storage::disk('public')->path($background), + 'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, + 'bundleRelative' => true, ]; } @@ -142,11 +143,11 @@ private function buildArrangements(Song $song): array $arrangements = []; foreach ($song->arrangements as $arrangement) { - $arrangement->loadMissing('arrangementLabels.label'); + $arrangement->loadMissing('arrangementSections.section.label'); - $groupNames = $arrangement->arrangementLabels + $groupNames = $arrangement->arrangementSections ->sortBy('order') - ->map(fn ($al) => $al->label?->name) + ->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name) ->filter() ->values() ->toArray(); diff --git a/app/Services/ProImportService.php b/app/Services/ProImportService.php index 645a03b..298676e 100644 --- a/app/Services/ProImportService.php +++ b/app/Services/ProImportService.php @@ -6,6 +6,7 @@ use App\Models\Song; use App\Models\SongArrangement; use App\Models\SongArrangementLabel; +use App\Models\SongSection; use App\Support\MacroColorConverter; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; @@ -104,14 +105,14 @@ private function upsertSong(ProSong $proSong): Song } $song->arrangements()->each(function (SongArrangement $arr) { - $arr->arrangementLabels()->delete(); + $arr->arrangementSections()->delete(); }); $song->arrangements()->delete(); $hasTranslation = false; - $labelsByName = []; + $sectionsByName = []; - foreach ($proSong->getGroups() as $proGroup) { + foreach ($proSong->getGroups() as $groupOrder => $proGroup) { $groupName = $proGroup->getName(); $existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first(); @@ -125,9 +126,14 @@ private function upsertSong(ProSong $proSong): Song ]); } - $labelsByName[$groupName] = $existingLabel; + $section = SongSection::firstOrCreate( + ['song_id' => $song->id, 'label_id' => $existingLabel->id], + ['order' => $groupOrder + 1], + ); + $section->update(['order' => $groupOrder + 1]); + $sectionsByName[$groupName] = $section; - $existingLabel->songSlides()->delete(); + $section->slides()->delete(); foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) { $translatedText = null; @@ -137,7 +143,7 @@ private function upsertSong(ProSong $proSong): Song $hasTranslation = true; } - $existingLabel->songSlides()->create([ + $section->slides()->create([ 'order' => $slidePosition, 'text_content' => $proSlide->getPlainText(), 'text_content_translated' => $translatedText, @@ -156,19 +162,19 @@ private function upsertSong(ProSong $proSong): Song $groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement); foreach ($groupsInArrangement as $order => $proGroup) { - $label = $labelsByName[$proGroup->getName()] ?? null; + $section = $sectionsByName[$proGroup->getName()] ?? null; - if ($label) { + if ($section) { SongArrangementLabel::create([ 'song_arrangement_id' => $arrangement->id, - 'label_id' => $label->id, + 'song_section_id' => $section->id, 'order' => $order, ]); } } } - return $song->fresh(['arrangements.arrangementLabels.label.songSlides']); + return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); } public static function rgbaToHex(array $rgba): string diff --git a/app/Services/ServiceImageResolver.php b/app/Services/ServiceImageResolver.php index eb23306..76aa29b 100644 --- a/app/Services/ServiceImageResolver.php +++ b/app/Services/ServiceImageResolver.php @@ -8,6 +8,18 @@ class ServiceImageResolver { + /** + * Fixed export filename for the key-visual image. The image bytes are + * embedded under this name AND referenced by this name inside the .pro file. + */ + public const KEY_VISUAL_EXPORT_NAME = 'KEY_VISUAL.jpg'; + + /** + * Fixed export filename for the background image. The image bytes are + * embedded under this name AND referenced by this name inside the .pro file. + */ + public const BACKGROUND_EXPORT_NAME = 'BACKGROUND.jpg'; + public function keyVisualFor(Service $service): ?string { return $this->resolve($service->key_visual_filename, 'current_key_visual'); diff --git a/app/Services/SongService.php b/app/Services/SongService.php index 4d3b457..44c7b64 100644 --- a/app/Services/SongService.php +++ b/app/Services/SongService.php @@ -6,15 +6,16 @@ use App\Models\Song; use App\Models\SongArrangement; use App\Models\SongArrangementLabel; +use App\Models\SongSection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class SongService { /** - * Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren. + * Sicherstellen, dass die Default-Labels und Song-Sektionen existieren. * - * @return Collection + * @return Collection */ public function createDefaultGroups(Song $song): Collection { @@ -24,9 +25,9 @@ public function createDefaultGroups(Song $song): Collection ['name' => 'Bridge', 'color' => '#F59E0B'], ]; - $labels = collect(); + $sections = collect(); - foreach ($defaults as $data) { + foreach ($defaults as $index => $data) { $existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first(); if ($existing === null) { @@ -36,10 +37,16 @@ public function createDefaultGroups(Song $song): Collection ]); } - $labels->push($existing); + $section = SongSection::firstOrCreate( + ['song_id' => $song->id, 'label_id' => $existing->id], + ['order' => $index + 1], + ); + $section->update(['order' => $index + 1]); + + $sections->push($section); } - return $labels; + return $sections; } /** @@ -52,16 +59,16 @@ public function createDefaultArrangement(Song $song): SongArrangement 'is_default' => true, ]); - $labels = $this->createDefaultGroups($song); + $sections = $this->createDefaultGroups($song); - foreach ($labels->values() as $index => $label) { - $arrangement->arrangementLabels()->create([ - 'label_id' => $label->id, + foreach ($sections->values() as $index => $section) { + $arrangement->arrangementSections()->create([ + 'song_section_id' => $section->id, 'order' => $index + 1, ]); } - return $arrangement->load('arrangementLabels.label'); + return $arrangement->load('arrangementSections.section.label'); } /** @@ -75,15 +82,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name) $clone->is_default = false; $clone->save(); - foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) { + foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) { SongArrangementLabel::create([ 'song_arrangement_id' => $clone->id, - 'label_id' => $arrangementLabel->label_id, - 'order' => $arrangementLabel->order, + 'song_section_id' => $arrangementSection->song_section_id, + 'order' => $arrangementSection->order, ]); } - return $clone->load('arrangementLabels.label'); + return $clone->load('arrangementSections.section.label'); }); } } diff --git a/app/Services/TranslationService.php b/app/Services/TranslationService.php index d372357..ddafe74 100644 --- a/app/Services/TranslationService.php +++ b/app/Services/TranslationService.php @@ -34,7 +34,7 @@ public function importTranslation(Song $song, string $text): void $defaultArr = $song->arrangements() ->where('is_default', true) - ->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides']) + ->with(['arrangementSections' => fn ($q) => $q->orderBy('order'), 'arrangementSections.section.slides']) ->first(); if ($defaultArr === null) { @@ -43,14 +43,14 @@ public function importTranslation(Song $song, string $text): void return; } - foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) { - $label = $arrangementLabel->label; + foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { + $section = $arrangementSection->section; - if ($label === null) { + if ($section === null) { continue; } - foreach ($label->songSlides->sortBy('order') as $slide) { + foreach ($section->slides->sortBy('order') as $slide) { $originalLineCount = count(explode("\n", $slide->text_content ?? '')); $chunk = array_slice($translatedLines, $offset, $originalLineCount); $offset += $originalLineCount; @@ -71,15 +71,13 @@ public function markAsTranslated(Song $song): void public function removeTranslation(Song $song): void { - $labelIds = $song->arrangements() - ->with('arrangementLabels') - ->get() - ->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id')) + $sectionIds = $song->sections() + ->pluck('id') ->unique() ->values(); - if ($labelIds->isNotEmpty()) { - SongSlide::whereIn('label_id', $labelIds)->update([ + if ($sectionIds->isNotEmpty()) { + SongSlide::whereIn('song_section_id', $sectionIds)->update([ 'text_content_translated' => null, ]); } diff --git a/app/Support/CcliLabels.php b/app/Support/CcliLabels.php index a43691d..50015a3 100644 --- a/app/Support/CcliLabels.php +++ b/app/Support/CcliLabels.php @@ -7,7 +7,7 @@ final class CcliLabels /** * Regex matching CCLI SongSelect section labels (English + German + variants). */ - public const SECTION_LABEL_PATTERN = '/^(Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu'; + public const SECTION_LABEL_PATTERN = '/^(Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu'; /** * Regex matching CCLI footer/metadata lines (copyright, CCLI number). @@ -18,6 +18,7 @@ final class CcliLabels * Bidirectional English ↔ German label kind mapping. */ public const LABEL_NAME_MAP = [ + 'Vers' => 'Verse', 'Strophe' => 'Verse', 'Refrain' => 'Chorus', 'Brücke' => 'Bridge', @@ -53,7 +54,7 @@ public static function normalizeLabelName(string $label): string { $trimmed = trim($label); - if (! preg_match('/^(?Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?\s+\d+[a-z]?)?$/iu', $trimmed, $matches)) { + if (! preg_match('/^(?Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?\s+\d+[a-z]?)?$/iu', $trimmed, $matches)) { return $trimmed; } @@ -70,7 +71,7 @@ public static function parseLabel(string $line): ?array { $trimmed = trim($line); - if (! preg_match('/^(?Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?:\s+(?\d+[a-z]?))?(?:\s*\((?Repeat|Wdh\.?)\))?(?:\s*[xX]\s*\d+)?$/iu', $trimmed, $matches)) { + if (! preg_match('/^(?Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?:\s+(?\d+[a-z]?))?(?:\s*\((?Repeat|Wdh\.?)\))?(?:\s*[xX]\s*\d+)?$/iu', $trimmed, $matches)) { return null; } diff --git a/database/factories/SongArrangementLabelFactory.php b/database/factories/SongArrangementLabelFactory.php old mode 100644 new mode 100755 index b4a649c..a2a16fb --- a/database/factories/SongArrangementLabelFactory.php +++ b/database/factories/SongArrangementLabelFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; -use App\Models\Label; use App\Models\SongArrangement; use App\Models\SongArrangementLabel; +use App\Models\SongSection; use Illuminate\Database\Eloquent\Factories\Factory; class SongArrangementLabelFactory extends Factory @@ -15,7 +15,7 @@ public function definition(): array { return [ 'song_arrangement_id' => SongArrangement::factory(), - 'label_id' => Label::factory(), + 'song_section_id' => SongSection::factory(), 'order' => $this->faker->numberBetween(0, 10), ]; } diff --git a/database/factories/SongArrangementSectionFactory.php b/database/factories/SongArrangementSectionFactory.php new file mode 100644 index 0000000..3773045 --- /dev/null +++ b/database/factories/SongArrangementSectionFactory.php @@ -0,0 +1,22 @@ + SongArrangement::factory(), + 'song_section_id' => SongSection::factory(), + 'order' => $this->faker->numberBetween(0, 10), + ]; + } +} diff --git a/database/factories/SongSectionFactory.php b/database/factories/SongSectionFactory.php new file mode 100644 index 0000000..3cfb489 --- /dev/null +++ b/database/factories/SongSectionFactory.php @@ -0,0 +1,22 @@ + Song::factory(), + 'label_id' => Label::factory(), + 'order' => $this->faker->numberBetween(0, 10), + ]; + } +} diff --git a/database/factories/SongSlideFactory.php b/database/factories/SongSlideFactory.php old mode 100644 new mode 100755 index 4b042ad..b170d06 --- a/database/factories/SongSlideFactory.php +++ b/database/factories/SongSlideFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use App\Models\Label; +use App\Models\SongSection; use App\Models\SongSlide; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,7 +13,7 @@ class SongSlideFactory extends Factory public function definition(): array { return [ - 'label_id' => Label::factory(), + 'song_section_id' => SongSection::factory(), 'order' => $this->faker->numberBetween(1, 12), 'text_content' => implode("\n", $this->faker->sentences(3)), 'text_content_translated' => $this->faker->optional()->sentence(), diff --git a/database/migrations/2026_05_31_120000_create_song_sections_and_rescope_slides.php b/database/migrations/2026_05_31_120000_create_song_sections_and_rescope_slides.php new file mode 100644 index 0000000..b930a80 --- /dev/null +++ b/database/migrations/2026_05_31_120000_create_song_sections_and_rescope_slides.php @@ -0,0 +1,246 @@ +id(); + $table->foreignId('song_id')->constrained()->cascadeOnDelete(); + $table->foreignId('label_id')->constrained('labels')->restrictOnDelete(); + $table->unsignedInteger('order')->default(0); + $table->timestamps(); + + $table->unique(['song_id', 'label_id']); + }); + + Schema::table('song_slides', function (Blueprint $table): void { + $table->foreignId('song_section_id')->nullable()->after('id')->constrained('song_sections')->cascadeOnDelete(); + }); + + Schema::table('song_arrangement_labels', function (Blueprint $table): void { + $table->foreignId('song_section_id')->nullable()->after('label_id')->constrained('song_sections')->cascadeOnDelete(); + }); + + $this->backfillSections(); + $this->backfillSlides(); + $this->backfillArrangementSections(); + + DB::table('song_slides')->whereNull('song_section_id')->delete(); + + if (DB::getDriverName() === 'sqlite') { + $this->finalizeSqliteTables(); + + return; + } + + if (DB::getDriverName() !== 'sqlite') { + Schema::table('song_slides', function (Blueprint $table): void { + $table->dropForeign(['label_id']); + }); + } + + Schema::table('song_slides', function (Blueprint $table): void { + $table->dropColumn('label_id'); + }); + + Schema::table('song_slides', function (Blueprint $table): void { + $table->foreignId('song_section_id')->nullable(false)->change(); + }); + + if (DB::getDriverName() !== 'sqlite') { + Schema::table('song_arrangement_labels', function (Blueprint $table): void { + $table->dropForeign(['label_id']); + }); + } + + Schema::table('song_arrangement_labels', function (Blueprint $table): void { + $table->dropColumn('label_id'); + }); + + Schema::table('song_arrangement_labels', function (Blueprint $table): void { + $table->foreignId('song_section_id')->nullable(false)->change(); + }); + } + + public function down(): void + { + throw new \RuntimeException('Destruktive Migration: kein Rollback. Backup einspielen.'); + } + + private function backfillSections(): void + { + DB::table('song_arrangements') + ->select('song_arrangements.song_id') + ->join('song_arrangement_labels', 'song_arrangement_labels.song_arrangement_id', '=', 'song_arrangements.id') + ->whereNotNull('song_arrangement_labels.label_id') + ->distinct() + ->orderBy('song_arrangements.song_id') + ->chunk(100, function ($songs): void { + foreach ($songs as $song) { + $this->backfillSectionsForSong((int) $song->song_id); + } + }); + } + + private function backfillSectionsForSong(int $songId): void + { + $labelIds = DB::table('song_arrangement_labels') + ->join('song_arrangements', 'song_arrangements.id', '=', 'song_arrangement_labels.song_arrangement_id') + ->where('song_arrangements.song_id', $songId) + ->whereNotNull('song_arrangement_labels.label_id') + ->distinct() + ->orderBy('song_arrangement_labels.label_id') + ->pluck('song_arrangement_labels.label_id') + ->map(fn ($labelId): int => (int) $labelId) + ->all(); + + if ($labelIds === []) { + return; + } + + $preferredArrangementId = DB::table('song_arrangements') + ->where('song_id', $songId) + ->orderByDesc('is_default') + ->orderByRaw("lower(name) = 'normal' desc") + ->orderBy('id') + ->value('id'); + + $preferredOrders = collect(); + + if ($preferredArrangementId !== null) { + $preferredOrders = DB::table('song_arrangement_labels') + ->select('label_id', DB::raw('min("order") as section_order')) + ->where('song_arrangement_id', $preferredArrangementId) + ->whereIn('label_id', $labelIds) + ->groupBy('label_id') + ->pluck('section_order', 'label_id'); + } + + $fallbackOrder = ((int) $preferredOrders->max()) + 1; + $now = now(); + $rows = []; + + foreach ($labelIds as $labelId) { + $rows[] = [ + 'song_id' => $songId, + 'label_id' => $labelId, + 'order' => $preferredOrders->has($labelId) ? (int) $preferredOrders->get($labelId) : $fallbackOrder++, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + DB::table('song_sections')->insertOrIgnore($rows); + } + + private function backfillSlides(): void + { + DB::table('song_sections') + ->select(['id', 'label_id']) + ->orderBy('id') + ->chunkById(100, function ($sections): void { + foreach ($sections as $section) { + $this->backfillSlidesForSection((int) $section->id, (int) $section->label_id); + } + }); + } + + private function backfillSlidesForSection(int $sectionId, int $labelId): void + { + DB::table('song_slides') + ->where('label_id', $labelId) + ->whereNull('song_section_id') + ->orderBy('order') + ->chunkById(200, function ($slides) use ($sectionId): void { + $now = now(); + $rows = []; + + foreach ($slides as $slide) { + $rows[] = [ + 'label_id' => $slide->label_id, + 'song_section_id' => $sectionId, + 'order' => $slide->order, + 'text_content' => $slide->text_content, + 'text_content_translated' => $slide->text_content_translated, + 'notes' => $slide->notes, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if ($rows !== []) { + DB::table('song_slides')->insert($rows); + } + }); + } + + private function backfillArrangementSections(): void + { + DB::table('song_arrangement_labels') + ->select([ + 'song_arrangement_labels.id', + 'song_arrangement_labels.label_id', + 'song_arrangements.song_id', + ]) + ->join('song_arrangements', 'song_arrangements.id', '=', 'song_arrangement_labels.song_arrangement_id') + ->whereNotNull('song_arrangement_labels.label_id') + ->orderBy('song_arrangement_labels.id') + ->chunkById(500, function ($arrangementLabels): void { + foreach ($arrangementLabels as $arrangementLabel) { + $sectionId = DB::table('song_sections') + ->where('song_id', $arrangementLabel->song_id) + ->where('label_id', $arrangementLabel->label_id) + ->value('id'); + + if ($sectionId !== null) { + DB::table('song_arrangement_labels') + ->where('id', $arrangementLabel->id) + ->update(['song_section_id' => $sectionId]); + } + } + }, 'song_arrangement_labels.id', 'id'); + } + + private function finalizeSqliteTables(): void + { + Schema::disableForeignKeyConstraints(); + + Schema::create('song_slides_new', function (Blueprint $table): void { + $table->id(); + $table->foreignId('song_section_id')->constrained('song_sections')->cascadeOnDelete(); + $table->unsignedInteger('order'); + $table->text('text_content'); + $table->text('text_content_translated')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + DB::statement('insert into song_slides_new (id, song_section_id, "order", text_content, text_content_translated, notes, created_at, updated_at) select id, song_section_id, "order", text_content, text_content_translated, notes, created_at, updated_at from song_slides'); + + Schema::drop('song_slides'); + Schema::rename('song_slides_new', 'song_slides'); + + Schema::create('song_arrangement_labels_new', function (Blueprint $table): void { + $table->id(); + $table->foreignId('song_arrangement_id')->constrained()->cascadeOnDelete(); + $table->foreignId('song_section_id')->constrained('song_sections')->cascadeOnDelete(); + $table->unsignedInteger('order'); + $table->timestamps(); + + $table->index(['song_arrangement_id', 'order']); + }); + + DB::statement('insert into song_arrangement_labels_new (id, song_arrangement_id, song_section_id, "order", created_at, updated_at) select id, song_arrangement_id, song_section_id, "order", created_at, updated_at from song_arrangement_labels'); + + Schema::drop('song_arrangement_labels'); + Schema::rename('song_arrangement_labels_new', 'song_arrangement_labels'); + + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/package-lock.json b/package-lock.json index ce3ef7b..850944d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -38,9 +38,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -48,13 +48,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -64,14 +64,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -520,9 +520,9 @@ } }, "node_modules/@inertiajs/core": { - "version": "2.3.23", - "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.23.tgz", - "integrity": "sha512-d+jcdf91RY5bjT+ivBn2gu9Qsmxx5CacvVmVp7o9H2Lu/k1cBD0m4UZB5YKWfG11aBCucShBxuppEXme7sSEow==", + "version": "2.3.24", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.24.tgz", + "integrity": "sha512-xAlUl5+RKtdbutEgsmdWa6HmnvjIGcWTrvfLj/3Icy3/7bSH3aiI+kuYPs17LBq/SMaXnqBZXXo094rEXUv2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -534,13 +534,13 @@ } }, "node_modules/@inertiajs/vue3": { - "version": "2.3.23", - "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.23.tgz", - "integrity": "sha512-W6rhjNCIKQQYZsNFdlFEa+prATbXcIR5k9VMg3PsYfRmqTYTORZspdrTH7JLPMneLuhNiI/Y6VbFR0TxGdv5Fg==", + "version": "2.3.24", + "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.24.tgz", + "integrity": "sha512-TokM+JU88YTHClh/LcKk31qiIAZFq3RQ4BBf1dxvk6MV45KWYemJMpLS6WFJ5NaSv6rZFlZrRc92N0ZdyOC/HA==", "dev": true, "license": "MIT", "dependencies": { - "@inertiajs/core": "2.3.23", + "@inertiajs/core": "2.3.24", "@types/lodash-es": "^4.17.12", "laravel-precognition": "^1.0.2", "lodash-es": "^4.18.1" @@ -617,13 +617,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -633,16 +633,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -654,9 +654,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -668,9 +668,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -682,9 +682,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -696,9 +696,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -710,9 +710,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -724,9 +724,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -738,9 +738,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -752,9 +752,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -766,9 +766,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -780,9 +780,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -794,9 +794,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -808,9 +808,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -822,9 +822,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -836,9 +836,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -850,9 +850,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -864,9 +864,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -878,9 +878,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -892,9 +892,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -906,9 +906,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -920,9 +920,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -934,9 +934,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -948,9 +948,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -962,9 +962,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -976,9 +976,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -1313,13 +1313,13 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", - "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.13" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1330,111 +1330,111 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", - "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.3", - "@vue/shared": "3.5.34", + "@vue/shared": "3.5.35", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", - "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", - "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.3", - "@vue/compiler-core": "3.5.34", - "@vue/compiler-dom": "3.5.34", - "@vue/compiler-ssr": "3.5.34", - "@vue/shared": "3.5.34", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.14", + "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", - "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" } }, "node_modules/@vue/reactivity": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", - "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/shared": "3.5.34" + "@vue/shared": "3.5.35" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", - "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", - "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.34", - "@vue/runtime-core": "3.5.34", - "@vue/shared": "3.5.34", + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", - "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" }, "peerDependencies": { - "vue": "3.5.34" + "vue": "3.5.35" } }, "node_modules/@vue/shared": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", - "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", "dev": true, "license": "MIT" }, @@ -1477,6 +1477,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1548,21 +1561,22 @@ } }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1638,9 +1652,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001792", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -1768,6 +1782,24 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1804,9 +1836,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.353", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", - "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", "dev": true, "license": "ISC" }, @@ -1818,9 +1850,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", - "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", "dev": true, "license": "MIT", "dependencies": { @@ -1865,9 +1897,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -2156,9 +2188,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -2168,6 +2200,20 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2540,6 +2586,13 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2560,11 +2613,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-inspect": { "version": "1.13.4", @@ -2600,13 +2656,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -2619,9 +2675,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2632,9 +2688,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2652,7 +2708,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2678,9 +2734,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2716,9 +2772,9 @@ } }, "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -2732,31 +2788,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -2935,9 +2991,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -3114,17 +3170,17 @@ } }, "node_modules/vue": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", - "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.34", - "@vue/compiler-sfc": "3.5.34", - "@vue/runtime-dom": "3.5.34", - "@vue/server-renderer": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" diff --git a/resources/js/Components/ArrangementConfigurator.vue b/resources/js/Components/ArrangementConfigurator.vue index 98adeca..dbd51b1 100644 --- a/resources/js/Components/ArrangementConfigurator.vue +++ b/resources/js/Components/ArrangementConfigurator.vue @@ -166,7 +166,7 @@ function saveArrangement() { `/arrangements/${selectedArrangement.value.id}`, { groups: arrangementGroups.value.map((group, index) => ({ - label_id: group.id, + section_id: group.section_id ?? group.id, order: index + 1, })), }, diff --git a/resources/js/Components/ArrangementDialog.vue b/resources/js/Components/ArrangementDialog.vue index a35e30b..de7d31f 100644 --- a/resources/js/Components/ArrangementDialog.vue +++ b/resources/js/Components/ArrangementDialog.vue @@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue' import { router } from '@inertiajs/vue3' import { VueDraggable } from 'vue-draggable-plus' import CcliPasteDialog from '@/Components/CcliPasteDialog.vue' +import SongEditModal from '@/Components/SongEditModal.vue' const MASTER_ID = 'master' @@ -44,6 +45,7 @@ const selectedSongId = ref('') const dropdownOpen = ref(false) const assignError = ref('') const ccliDialogOpen = ref(false) +const editSongId = ref(null) function normalize(value) { return (value ?? '').toString().toLowerCase().trim() @@ -552,7 +554,7 @@ function closeOnBackdrop(e) { + + + diff --git a/resources/js/Components/CcliPasteDialog.vue b/resources/js/Components/CcliPasteDialog.vue index 4b14a7a..fe88682 100644 --- a/resources/js/Components/CcliPasteDialog.vue +++ b/resources/js/Components/CcliPasteDialog.vue @@ -10,7 +10,7 @@ const props = defineProps({ prefilledText: { type: String, default: null }, }) -const emit = defineEmits(['close', 'imported', 'paired']) +const emit = defineEmits(['close', 'imported', 'paired', 'edit-song']) const pasteText = ref('') const preview = ref(null) @@ -94,7 +94,8 @@ async function doImport(importMode) { } if (importMode === 'edit') { - router.visit('/songs/' + data.song_id) + emit('edit-song', data.song_id) + emit('close') } else if (importMode === 'pair') { router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true') } else { @@ -110,7 +111,8 @@ async function doImport(importMode) { diff --git a/resources/js/Components/SongEditModal.vue b/resources/js/Components/SongEditModal.vue index 6f6b590..85b6599 100644 --- a/resources/js/Components/SongEditModal.vue +++ b/resources/js/Components/SongEditModal.vue @@ -21,17 +21,77 @@ const emit = defineEmits(['close', 'updated']) const loading = ref(false) const error = ref(null) const songData = ref(null) +const sectionDrafts = ref({}) const title = ref('') const ccliId = ref('') const copyrightText = ref('') +const showAddSectionForm = ref(false) +const newSectionLabel = ref('') +const newSectionText = ref('') const saving = ref(false) const saved = ref(false) let savedTimeout = null +const sectionSaveDebouncers = new Map() + +/* ── Save status ── */ + +function startSaving() { + saving.value = true + saved.value = false +} + +function finishSaving() { + saving.value = false + saved.value = true + + if (savedTimeout) clearTimeout(savedTimeout) + savedTimeout = setTimeout(() => { + saved.value = false + }, 2000) +} + +function stopSaving() { + saving.value = false +} /* ── Data fetching ── */ +function slidesToText(slides = [], key) { + return slides + .map((slide) => slide[key] ?? '') + .filter((text) => text !== '') + .join('\n\n') +} + +function sectionKey(group) { + return group.section_id ?? group.id +} + +function setSectionDrafts(data) { + const drafts = {} + + ;(data?.groups ?? []).forEach((group) => { + drafts[sectionKey(group)] = { + text: slidesToText(group.slides, 'text_content'), + translated: slidesToText(group.slides, 'text_content_translated'), + } + }) + + sectionDrafts.value = drafts +} + +function draftFor(group) { + const key = sectionKey(group) + + if (!sectionDrafts.value[key]) { + sectionDrafts.value[key] = { text: '', translated: '' } + } + + return sectionDrafts.value[key] +} + const fetchSong = async () => { if (!props.songId) return @@ -53,6 +113,7 @@ const fetchSong = async () => { const json = await response.json() songData.value = json.data + setSectionDrafts(json.data) title.value = json.data.title ?? '' ccliId.value = json.data.ccli_id ?? '' @@ -73,7 +134,11 @@ watch( if (!isVisible) { songData.value = null + sectionDrafts.value = {} error.value = null + showAddSectionForm.value = false + newSectionLabel.value = '' + newSectionText.value = '' } }, ) @@ -83,8 +148,7 @@ watch( const performSave = async (data) => { if (!props.songId) return - saving.value = true - saved.value = false + startSaving() try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' @@ -105,17 +169,11 @@ const performSave = async (data) => { throw new Error('Speichern fehlgeschlagen') } - saving.value = false - saved.value = true - - if (savedTimeout) clearTimeout(savedTimeout) - savedTimeout = setTimeout(() => { - saved.value = false - }, 2000) + finishSaving() emit('updated') } catch { - saving.value = false + stopSaving() } } @@ -150,10 +208,12 @@ const arrangements = computed(() => { name: arr.name, is_default: arr.is_default, groups: arr.arrangement_groups.map((ag) => { - const group = songData.value.groups.find((g) => g.id === ag.label_id) + const group = songData.value.groups.find((g) => g.id === (ag.section_id ?? ag.label_id)) return { - id: ag.label_id, + id: ag.section_id ?? ag.label_id, + section_id: ag.section_id ?? ag.label_id, + label_id: ag.label_id, name: group?.name ?? 'Unbekannt', color: group?.color ?? '#6b7280', order: ag.order, @@ -167,11 +227,161 @@ const availableGroups = computed(() => { return songData.value.groups.map((group) => ({ id: group.id, + section_id: group.section_id ?? group.id, + label_id: group.label_id, name: group.name, color: group.color, })) }) +const sectionLabelOptions = computed(() => { + if (!songData.value?.groups) return [] + + return [...new Set(songData.value.groups.map((group) => group.name).filter(Boolean))] +}) + +/* ── Section editing ── */ + +function splitSectionText(value) { + const trimmed = (value ?? '').replace(/\r\n/g, '\n').replace(/\s+$/u, '') + const blocks = trimmed + .split(/\n\s*\n+/u) + .map((block) => block.trim()) + .filter((block) => block !== '') + + return blocks.length ? blocks : [''] +} + +function buildSectionSlides(sectionId) { + const draft = sectionDrafts.value[sectionId] ?? { text: '', translated: '' } + const textBlocks = splitSectionText(draft.text) + const translatedBlocks = (draft.translated ?? '').trim() === '' ? [] : splitSectionText(draft.translated) + + return textBlocks.map((text, index) => ({ + text_content: text, + text_content_translated: translatedBlocks[index] ?? null, + })) +} + +async function saveSection(sectionId) { + if (!props.songId || !sectionDrafts.value[sectionId]) return + + startSaving() + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' + const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': csrfToken, + }, + credentials: 'same-origin', + body: JSON.stringify({ + slides: buildSectionSlides(sectionId), + }), + }) + + if (!response.ok) { + throw new Error('Sektion konnte nicht gespeichert werden.') + } + + const json = await response.json() + songData.value = json.data + setSectionDrafts(json.data) + finishSaving() + emit('updated') + } catch { + stopSaving() + } +} + +function onSectionInput(sectionId) { + if (!sectionSaveDebouncers.has(sectionId)) { + sectionSaveDebouncers.set(sectionId, useDebounceFn(() => { + saveSection(sectionId) + }, 600)) + } + + sectionSaveDebouncers.get(sectionId)() +} + +function onSectionBlur(sectionId) { + sectionSaveDebouncers.get(sectionId)?.cancel?.() + saveSection(sectionId) +} + +async function deleteSection(sectionId) { + if (!window.confirm('Möchtest Du diese Sektion wirklich löschen?')) return + + startSaving() + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' + const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': csrfToken, + }, + credentials: 'same-origin', + }) + + if (!response.ok) { + throw new Error('Sektion konnte nicht gelöscht werden.') + } + + const json = await response.json() + songData.value = json.data + setSectionDrafts(json.data) + finishSaving() + emit('updated') + } catch { + stopSaving() + } +} + +async function addSection() { + if (!props.songId || !newSectionLabel.value.trim()) return + + startSaving() + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' + const response = await fetch(route('songs.sections.store', props.songId), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': csrfToken, + }, + credentials: 'same-origin', + body: JSON.stringify({ + label_name: newSectionLabel.value, + slides: splitSectionText(newSectionText.value).map((text) => ({ text_content: text })), + }), + }) + + if (!response.ok) { + throw new Error('Sektion konnte nicht hinzugefügt werden.') + } + + newSectionLabel.value = '' + newSectionText.value = '' + showAddSectionForm.value = false + + await fetchSong() + finishSaving() + emit('updated') + } catch { + stopSaving() + } +} + /* ── Close handling ── */ const closeOnEscape = (e) => { @@ -481,6 +691,155 @@ onUnmounted(() => { + +
+
+
+

+ Sektionen +

+ +

+ Leerzeilen trennen einzelne Folien. Änderungen speichern automatisch. +

+
+ + +
+ +
+
+
+ + + + +
+ +
+ +