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.
This commit is contained in:
Thorsten Bus 2026-05-31 14:45:47 +02:00
parent e95abbc1e6
commit ae42b48753
63 changed files with 2255 additions and 631 deletions

View file

@ -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. 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' => '<FIXED_NAME>'` 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 ### Key files
| File | Purpose | | File | Purpose |

View file

@ -5,9 +5,11 @@
use App\Models\Label; use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongSection;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArrangementController extends Controller class ArrangementController extends Controller
{ {
@ -29,18 +31,18 @@ public function store(Request $request, Song $song): RedirectResponse
return; 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, 'song_arrangement_id' => $arrangement->id,
'label_id' => $al->label_id, 'song_section_id' => $arrangementSection->song_section_id,
'order' => $index + 1, 'order' => $index + 1,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
])->all(); ])->all();
if ($rows !== []) { 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 { DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementLabels'); $arrangement->loadMissing('arrangementSections');
$clone = $arrangement->song->arrangements()->create([ $clone = $arrangement->song->arrangements()->create([
'name' => $data['name'], 'name' => $data['name'],
@ -71,22 +73,23 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
{ {
$data = $request->validate([ $data = $request->validate([
'groups' => ['array'], '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'], 'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'], 'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], '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 { DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
$arrangement->arrangementLabels()->delete(); $arrangement->arrangementSections()->delete();
$rows = $labelIds $rows = $sectionIds
->values() ->values()
->map(fn (int $labelId, int $index) => [ ->map(fn (int $sectionId, int $index) => [
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $labelId, 'song_section_id' => $sectionId,
'order' => $index + 1, 'order' => $index + 1,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
@ -94,12 +97,19 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
->all(); ->all();
if ($rows !== []) { if ($rows !== []) {
$arrangement->arrangementLabels()->insert($rows); $arrangement->arrangementSections()->insert($rows);
} }
if (! empty($data['group_colors'])) { if (! empty($data['group_colors'])) {
foreach ($data['group_colors'] as $labelId => $color) { $sections = SongSection::whereIn('id', collect(array_keys($data['group_colors']))->map(fn ($id) => (int) $id))
Label::whereKey((int) $labelId)->update(['color' => $color]); ->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; return;
} }
$arrangementLabels = $source->arrangementLabels $arrangementSections = $source->arrangementSections
->sortBy('order') ->sortBy('order')
->values(); ->values();
$rows = $arrangementLabels $rows = $arrangementSections
->map(fn ($arrangementLabel) => [ ->map(fn ($arrangementSection) => [
'song_arrangement_id' => $target->id, 'song_arrangement_id' => $target->id,
'label_id' => $arrangementLabel->label_id, 'song_section_id' => $arrangementSection->song_section_id,
'order' => $arrangementLabel->order, 'order' => $arrangementSection->order,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]) ])
->all(); ->all();
if ($rows !== []) { 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;
}
} }

View file

@ -2,14 +2,19 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
final class BookmarkletController extends Controller final class BookmarkletController extends Controller
{ {
public function show(): Response public function show(Request $request): Response
{ {
$appUrl = rtrim($request->getSchemeAndHttpHost(), '/');
if ($appUrl === '') {
$appUrl = rtrim((string) Config::get('app.url', ''), '/'); $appUrl = rtrim((string) Config::get('app.url', ''), '/');
}
$bookmarkletScript = <<<'BOOKMARKLET' $bookmarkletScript = <<<'BOOKMARKLET'
(function(){ (function(){
@ -18,20 +23,42 @@ public function show(): Response
alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).'); alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).');
return; return;
} }
var title = (document.querySelector('h1, .song-title, [class*="title"]') || {}).innerText || document.title || ''; function send(text){
var author = (document.querySelector('.song-authors, .song-artist, [class*="author"]') || {}).innerText || ''; var ccliMatch = (text || '').match(/CCLI[\s#-]*(\d+)/i);
var bodyText = document.body ? document.body.innerText : '';
var ccliMatch = bodyText.match(/CCLI[\s#-]*(\d+)/i);
var ccliId = ccliMatch ? ccliMatch[1] : '';
var payload = { var payload = {
title: title.trim(), title: '',
author: author.trim(), author: '',
ccliId: ccliId, ccliId: ccliMatch ? ccliMatch[1] : '',
sourceUrl: location.href, sourceUrl: location.href,
rawText: bodyText rawText: text || ''
}; };
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank'); 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; BOOKMARKLET;

View file

@ -131,13 +131,14 @@ public function edit(Service $service): Response
$service->load([ $service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song', 'serviceSongs.song',
'serviceSongs.song.arrangements.arrangementLabels.label', 'serviceSongs.song.arrangements.arrangementSections.section.label',
'serviceSongs.arrangement', 'serviceSongs.arrangement',
'slides', 'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides', 'agendaItems.slides',
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides', 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
'agendaItems.serviceSong.arrangement.arrangementLabels.label', 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
]); ]);
$songsCatalog = Song::query() $songsCatalog = Song::query()
@ -295,13 +296,14 @@ public function edit(Service $service): Response
'id' => $arrangement->id, 'id' => $arrangement->id,
'name' => $arrangement->name, 'name' => $arrangement->name,
'is_default' => $arrangement->is_default, 'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementLabels 'groups' => $arrangement->arrangementSections
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($arrangementLabel) => [ ->map(fn ($arrangementSection) => [
'id' => $arrangementLabel->label?->id, 'id' => $arrangementSection->section?->label?->id,
'name' => $arrangementLabel->label?->name, 'section_id' => $arrangementSection->section?->id,
'color' => $arrangementLabel->label?->color, 'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
]) ])
->filter(fn ($group) => $group['id'] !== null) ->filter(fn ($group) => $group['id'] !== null)
->values(), ->values(),
@ -474,14 +476,15 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection
return collect(); return collect();
} }
return $defaultArr->arrangementLabels return $defaultArr->arrangementSections
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($arrangementLabel) => [ ->map(fn ($arrangementSection) => [
'id' => $arrangementLabel->label?->id, 'id' => $arrangementSection->section?->label?->id,
'name' => $arrangementLabel->label?->name, 'section_id' => $arrangementSection->section?->id,
'color' => $arrangementLabel->label?->color, 'name' => $arrangementSection->section?->label?->name,
'order' => $arrangementLabel->order, 'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order,
]) ])
->filter(fn ($group) => $group['id'] !== null) ->filter(fn ($group) => $group['id'] !== null)
->values(); ->values();

View file

@ -62,13 +62,13 @@ public function store(SongRequest $request): JsonResponse
return response()->json([ return response()->json([
'message' => 'Song erfolgreich erstellt', '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); ], 201);
} }
public function show(int $id): JsonResponse 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) { if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404); return response()->json(['message' => 'Song nicht gefunden'], 404);
@ -91,7 +91,7 @@ public function update(SongRequest $request, int $id): JsonResponse
return response()->json([ return response()->json([
'message' => 'Song erfolgreich aktualisiert', '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); $defaultArr = $song->arrangements->firstWhere('is_default', true);
$groupsPayload = []; $groupsPayload = [];
if ($defaultArr !== null) { if ($defaultArr !== null) {
$groupsPayload = $defaultArr->arrangementLabels $groupsPayload = $defaultArr->arrangementSections
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($al) => [ ->map(fn ($arrangementSection) => [
'id' => $al->label?->id, 'id' => $arrangementSection->section?->id,
'name' => $al->label?->name, 'section_id' => $arrangementSection->section?->id,
'color' => $al->label?->color, 'label_id' => $arrangementSection->section?->label_id,
'order' => $al->order, 'name' => $arrangementSection->section?->label?->name,
'slides' => $al->label 'color' => $arrangementSection->section?->label?->color,
? $al->label->songSlides 'order' => $arrangementSection->order,
'slides' => $arrangementSection->section
? $arrangementSection->section->slides
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($slide) => [ ->map(fn ($slide) => [
@ -157,10 +159,11 @@ private function formatSongDetail(Song $song): array
'id' => $arr->id, 'id' => $arr->id,
'name' => $arr->name, 'name' => $arr->name,
'is_default' => $arr->is_default, 'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [ 'arrangement_groups' => $arr->arrangementSections->sortBy('order')->values()->map(fn ($arrangementSection) => [
'id' => $al->id, 'id' => $arrangementSection->id,
'label_id' => $al->label_id, 'section_id' => $arrangementSection->song_section_id,
'order' => $al->order, 'label_id' => $arrangementSection->section?->label_id,
'order' => $arrangementSection->order,
])->toArray(), ])->toArray(),
])->toArray(), ])->toArray(),
]; ];

View file

@ -57,21 +57,23 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
private function buildGroupsInOrder(SongArrangement $arrangement): array private function buildGroupsInOrder(SongArrangement $arrangement): array
{ {
$arrangement->load([ $arrangement->load([
'arrangementLabels' => fn ($query) => $query->orderBy('order'), 'arrangementSections' => fn ($query) => $query->orderBy('order'),
'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'), 'arrangementSections.section.slides' => fn ($query) => $query->orderBy('order'),
'arrangementSections.section.label',
]); ]);
return $arrangement->arrangementLabels->map(function ($arrangementLabel) { return $arrangement->arrangementSections->map(function ($arrangementSection) {
$label = $arrangementLabel->label; $section = $arrangementSection->section;
$label = $section?->label;
if ($label === null) { if ($section === null || $label === null) {
return null; return null;
} }
return [ return [
'name' => $label->name, 'name' => $label->name,
'color' => $label->color ?? '#6b7280', 'color' => $label->color ?? '#6b7280',
'slides' => $label->songSlides->map(fn ($slide) => [ 'slides' => $section->slides->map(fn ($slide) => [
'text_content' => $slide->text_content, 'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated, 'text_content_translated' => $slide->text_content_translated,
])->values()->all(), ])->values()->all(),

View file

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use App\Support\CcliLabels;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SongSectionController extends Controller
{
private const DEFAULT_LABEL_COLOR = '#3B82F6';
public function __construct(
private readonly SongController $songController,
) {}
public function store(Request $request, Song $song): JsonResponse
{
$data = $request->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.',
];
}
}

View file

@ -19,24 +19,27 @@ public function page(Song $song): Response
{ {
$song->load([ $song->load([
'arrangements' => fn ($q) => $q->where('is_default', true), 'arrangements' => fn ($q) => $q->where('is_default', true),
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangements.arrangementSections' => fn ($q) => $q->orderBy('order'),
'arrangements.arrangementLabels.label.songSlides', 'arrangements.arrangementSections.section.slides',
'arrangements.arrangementSections.section.label',
]); ]);
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
$groups = collect(); $groups = collect();
if ($defaultArr !== null) { if ($defaultArr !== null) {
$groups = $defaultArr->arrangementLabels $groups = $defaultArr->arrangementSections
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($al) => [ ->map(fn ($arrangementSection) => [
'id' => $al->label?->id, 'id' => $arrangementSection->section?->id,
'name' => $al->label?->name, 'section_id' => $arrangementSection->section?->id,
'color' => $al->label?->color, 'label_id' => $arrangementSection->section?->label_id,
'order' => $al->order, 'name' => $arrangementSection->section?->label?->name,
'slides' => $al->label 'color' => $arrangementSection->section?->label?->color,
? $al->label->songSlides 'order' => $arrangementSection->order,
'slides' => $arrangementSection->section
? $arrangementSection->section->slides
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($slide) => [ ->map(fn ($slide) => [

View file

@ -25,16 +25,16 @@ protected function casts(): array
]; ];
} }
public function songSlides(): HasMany
{
return $this->hasMany(SongSlide::class);
}
public function macroAssignments(): HasMany public function macroAssignments(): HasMany
{ {
return $this->hasMany(MacroAssignment::class); return $this->hasMany(MacroAssignment::class);
} }
public function sections(): HasMany
{
return $this->hasMany(SongSection::class);
}
public function isHidden(): bool public function isHidden(): bool
{ {
return $this->hidden_at !== null; return $this->hidden_at !== null;

View file

@ -41,6 +41,11 @@ public function arrangements(): HasMany
return $this->hasMany(SongArrangement::class); return $this->hasMany(SongArrangement::class);
} }
public function sections(): HasMany
{
return $this->hasMany(SongSection::class)->orderBy('order');
}
public function serviceSongs(): HasMany public function serviceSongs(): HasMany
{ {
return $this->hasMany(ServiceSong::class); return $this->hasMany(ServiceSong::class);

View file

@ -31,7 +31,12 @@ public function song(): BelongsTo
public function arrangementLabels(): HasMany 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 public function serviceSongs(): HasMany

View file

@ -2,27 +2,4 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; class SongArrangementLabel extends SongArrangementSection {}
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);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SongArrangementSection extends Model
{
use HasFactory;
protected $table = 'song_arrangement_labels';
protected $fillable = [
'song_arrangement_id',
'song_section_id',
'order',
];
public function arrangement(): BelongsTo
{
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
}
public function section(): BelongsTo
{
return $this->belongsTo(SongSection::class, 'song_section_id');
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SongSection extends Model
{
use HasFactory;
protected $fillable = [
'song_id',
'label_id',
'order',
];
protected function casts(): array
{
return [
'order' => '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');
}
}

View file

@ -11,15 +11,15 @@ class SongSlide extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'label_id', 'song_section_id',
'order', 'order',
'text_content', 'text_content',
'text_content_translated', 'text_content_translated',
'notes', 'notes',
]; ];
public function label(): BelongsTo public function section(): BelongsTo
{ {
return $this->belongsTo(Label::class); return $this->belongsTo(SongSection::class, 'song_section_id');
} }
} }

View file

@ -9,7 +9,7 @@
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSlide; use App\Models\SongSection;
use App\Services\DTO\ParsedCcliSection; use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong; use App\Services\DTO\ParsedCcliSong;
use App\Support\CcliLabels; use App\Support\CcliLabels;
@ -18,7 +18,18 @@
final class CcliImportService 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( public function __construct(
private readonly CcliPasteParser $parser, 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(); $song = Song::withTrashed()->where('ccli_id', $parsed->ccliId)->first();
$status = 'created'; $status = 'created';
if ($song !== null && ! $song->trashed()) { if ($song !== null && ! $song->trashed() && $this->songHasContent($song)) {
throw new DuplicateCcliSongException($song->id); 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.'; $warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.';
} }
$labelIds = []; $sectionIds = [];
$hasTranslation = false; $hasTranslation = false;
foreach ($parsed->sections as $section) { foreach ($parsed->sections as $order => $parsedSection) {
$label = $this->resolveLabel($section); $label = $this->resolveLabel($parsedSection);
$labelIds[] = $label->id; $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) { foreach ($parsedSection->lines as $slideOrder => $line) {
$translatedLine = $section->linesTranslated[$order] ?? null; $translatedLine = $parsedSection->linesTranslated[$slideOrder] ?? null;
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== ''); $hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
SongSlide::create([ $section->slides()->create([
'label_id' => $label->id, 'order' => $slideOrder + 1,
'order' => $order + 1,
'text_content' => $line, 'text_content' => $line,
'text_content_translated' => $translatedLine, '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(); SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete();
foreach ($labelIds as $order => $labelId) { foreach ($sectionIds as $order => $sectionId) {
SongArrangementLabel::create([ SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $labelId, 'song_section_id' => $sectionId,
'order' => $order + 1, 'order' => $order + 1,
]); ]);
} }
$song = $song->fresh(['arrangements.arrangementLabels.label.songSlides']); $song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
ApiRequestLog::create([ ApiRequestLog::create([
'method' => 'import', '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])); 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 private function resolveLabel(ParsedCcliSection $section): Label
{ {
$canonicalKind = CcliLabels::normalizeLabelName($section->kind);
$canonicalLabelName = CcliLabels::normalizeLabelName( $canonicalLabelName = CcliLabels::normalizeLabelName(
$section->kind.($section->number ? ' '.$section->number : ''), $section->kind.($section->number ? ' '.$section->number : ''),
); );
return Label::firstOrCreate( return Label::firstOrCreate(
['name' => $canonicalLabelName], ['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)];
}
} }

View file

@ -53,13 +53,30 @@ public function parse(string $rawText): ParsedCcliSong
$copyrightText = null; $copyrightText = null;
$sections = []; $sections = [];
$current = null; $current = null;
$previousLineWasBlank = false;
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
foreach (array_slice($lines, $firstSectionIndex) as $line) { foreach (array_slice($lines, $firstSectionIndex) as $line) {
if ($line === '') { if ($line === '') {
$previousLineWasBlank = true;
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
continue; continue;
} }
if ($isMetadataLine($line)) { if ($isMetadataLine($line)) {
if ($author === null
&& $current !== null
&& $currentParagraphLineCount === 1
&& $currentParagraphStartedAfterBlank
) {
$author = array_pop($current['lines']);
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
}
$extractedCcliId = CcliLabels::extractCcliId($line); $extractedCcliId = CcliLabels::extractCcliId($line);
if ($extractedCcliId !== null) { if ($extractedCcliId !== null) {
$ccliId = $extractedCcliId; $ccliId = $extractedCcliId;
@ -94,12 +111,21 @@ public function parse(string $rawText): ParsedCcliSong
'modifier' => $label['modifier'], 'modifier' => $label['modifier'],
'lines' => [], 'lines' => [],
]; ];
$previousLineWasBlank = false;
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
continue; continue;
} }
if ($current !== null) { if ($current !== null) {
if ($currentParagraphLineCount === 0) {
$currentParagraphStartedAfterBlank = $previousLineWasBlank;
}
$current['lines'][] = $line; $current['lines'][] = $line;
$currentParagraphLineCount++;
$previousLineWasBlank = false;
} }
} }

View file

@ -29,7 +29,7 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa
{ {
$parsed = $this->parser->parse($ccliRawText); $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); $arrangement = $this->findArrangement($localSong, $arrangementName);
@ -47,16 +47,17 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa
$unmatchedLabels = []; $unmatchedLabels = [];
$allDistributedLines = []; $allDistributedLines = [];
foreach ($arrangement->arrangementLabels->sortBy('order') as $arrangementLabel) { foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) {
$label = $arrangementLabel->label; $section = $arrangementSection->section;
$label = $section?->label;
if ($label === null) { if ($section === null || $label === null) {
continue; continue;
} }
$localCanonical = $this->canonicalLabel($label->name, null); $localCanonical = $this->canonicalLabel($label->name, null);
$matchedSection = $ccliByCanonical[$localCanonical] ?? null; $matchedSection = $ccliByCanonical[$localCanonical] ?? null;
$slides = $label->songSlides->sortBy('order')->values(); $slides = $section->slides->sortBy('order')->values();
if ($matchedSection === null) { if ($matchedSection === null) {
$unmatchedLabels[] = $label->name; $unmatchedLabels[] = $label->name;

View file

@ -21,8 +21,9 @@ public function generatePlaylist(Service $service): array
->orderBy('sort_order') ->orderBy('sort_order')
->with([ ->with([
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
'serviceSong.song.arrangements.arrangementLabels.label.songSlides', 'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.arrangement.arrangementLabels.label', 'serviceSong.song.arrangements.arrangementSections.section.label',
'serviceSong.arrangement.arrangementSections.section.label',
]) ])
->get(); ->get();
@ -111,6 +112,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
rename($proPath, $destPath); rename($proPath, $destPath);
$embeddedFiles[$proFilename] = file_get_contents($destPath); $embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [ $playlistItems[] = [
'type' => 'presentation', 'type' => 'presentation',
@ -201,12 +203,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
*/ */
private function generatePlaylistLegacy(Service $service): array 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() $matchedSongs = $service->serviceSongs()
->whereNotNull('song_id') ->whereNotNull('song_id')
->orderBy('order') ->orderBy('order')
->with('song.arrangements.arrangementLabels.label.songSlides') ->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
->get(); ->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
@ -243,6 +245,7 @@ private function generatePlaylistLegacy(Service $service): array
rename($proPath, $destPath); rename($proPath, $destPath);
$embeddedFiles[$proFilename] = file_get_contents($destPath); $embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [ $playlistItems[] = [
'type' => 'presentation', 'type' => 'presentation',
@ -302,6 +305,7 @@ private function addSlidesFromCollection(
$slideDataList = []; $slideDataList = [];
$imageFiles = []; $imageFiles = [];
$background = $this->backgroundData($service); $background = $this->backgroundData($service);
$backgroundAttached = false;
foreach ($slides->values() as $index => $slide) { foreach ($slides->values() as $index => $slide) {
$storedPath = Storage::disk('public')->path($slide->stored_filename); $storedPath = Storage::disk('public')->path($slide->stored_filename);
@ -324,11 +328,16 @@ private function addSlidesFromCollection(
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background; $singleSlideData['background'] = $background;
$backgroundAttached = true;
} }
$slideDataList[] = $singleSlideData; $slideDataList[] = $singleSlideData;
} }
if ($backgroundAttached) {
$this->embedBackground($service, $embeddedFiles);
}
if (empty($slideDataList)) { if (empty($slideDataList)) {
return; return;
} }
@ -425,6 +434,8 @@ private function addKeyVisualFallbackPresentation(
return; return;
} }
$this->embedKeyVisual($service, $embeddedFiles);
$label = $item->title ?: 'Keyvisual'; $label = $item->title ?: 'Keyvisual';
$groups = [ $groups = [
[ [
@ -492,6 +503,8 @@ private function addKeyVisualSlide(Service $service, string $tempDir, array &$pl
return; return;
} }
$this->embedKeyVisual($service, $embeddedFiles);
$slideData = ['imageOnly' => true, 'background' => $kvData]; $slideData = ['imageOnly' => true, 'background' => $kvData];
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles); $this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
} }
@ -531,36 +544,92 @@ private function backgroundData(?Service $service): ?array
return null; return null;
} }
$background = app(ServiceImageResolver::class)->backgroundFor($service); if ($this->backgroundSourcePath($service) === null) {
if ($background === null) {
return null; return null;
} }
return [ return [
'path' => Storage::disk('public')->path($background), 'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG', 'format' => 'JPG',
'width' => 1920, 'width' => 1920,
'height' => 1080, 'height' => 1080,
'bundleRelative' => true,
]; ];
} }
private function keyVisualData(Service $service): ?array private function keyVisualData(Service $service): ?array
{ {
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service); if ($this->keyVisualSourcePath($service) === null) {
if ($keyVisual === null) {
return null; return null;
} }
return [ return [
'path' => Storage::disk('public')->path($keyVisual), 'path' => ServiceImageResolver::KEY_VISUAL_EXPORT_NAME,
'format' => 'JPG', 'format' => 'JPG',
'width' => 1920, 'width' => 1920,
'height' => 1080, '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 private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{ {
if ($background === null) { if ($background === null) {

View file

@ -43,7 +43,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
$agendaItem->loadMissing([ $agendaItem->loadMissing([
'service', 'service',
'slides', 'slides',
'serviceSong.song.arrangements.arrangementLabels.label.songSlides', 'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.song.arrangements.arrangementSections.section.label',
]); ]);
$title = $agendaItem->title ?: 'Ablauf-Element'; $title = $agendaItem->title ?: 'Ablauf-Element';
@ -63,7 +64,10 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service); $parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
$proFilename = self::safeFilename($song->title).'.pro'; $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'; $bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
ProBundleWriter::write($bundle, $bundlePath); ProBundleWriter::write($bundle, $bundlePath);
@ -95,6 +99,7 @@ private function buildBundleFromSlides(
$slideData = []; $slideData = [];
$mediaFiles = []; $mediaFiles = [];
$background = $this->backgroundData($service); $background = $this->backgroundData($service);
$backgroundAttached = false;
foreach ($slides as $slide) { foreach ($slides as $slide) {
$sourcePath = Storage::disk('public')->path($slide->stored_filename); $sourcePath = Storage::disk('public')->path($slide->stored_filename);
@ -118,6 +123,7 @@ private function buildBundleFromSlides(
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background; $singleSlideData['background'] = $background;
$backgroundAttached = true;
} }
if ($service !== null && $partType !== null) { if ($service !== null && $partType !== null) {
@ -138,6 +144,10 @@ private function buildBundleFromSlides(
$slideData[] = $singleSlideData; $slideData[] = $singleSlideData;
} }
if ($backgroundAttached) {
$this->embedBackground($service, $mediaFiles);
}
$groups = [ $groups = [
[ [
'name' => $groupName, 'name' => $groupName,
@ -164,6 +174,22 @@ private function buildBundleFromSlides(
} }
private function backgroundData(?Service $service): ?array 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) { if ($service === null) {
return null; return null;
@ -171,16 +197,26 @@ private function backgroundData(?Service $service): ?array
$background = $this->imageResolver->backgroundFor($service); $background = $this->imageResolver->backgroundFor($service);
if ($background === null) { if ($background === null || ! Storage::disk('public')->exists($background)) {
return null; return null;
} }
return [ return Storage::disk('public')->path($background);
'path' => Storage::disk('public')->path($background), }
'format' => 'JPG',
'width' => 1920, /** Embed the resolved background image bytes into the bundle under the fixed export name. */
'height' => 1080, 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 private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool

View file

@ -4,7 +4,6 @@
use App\Models\Service; use App\Models\Service;
use App\Models\Song; use App\Models\Song;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
class ProExportService 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 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( return ProFileGenerator::generate(
$song->title, $song->title,
@ -49,29 +48,30 @@ private function buildGroups(Song $song, ?Service $service = null): array
return []; return [];
} }
$defaultArr->loadMissing('arrangementLabels.label.songSlides'); $defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
$groups = []; $groups = [];
$seenLabelIds = []; $seenSectionIds = [];
$background = $this->backgroundData($service); $background = $this->backgroundData($service);
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) { foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$label = $arrangementLabel->label; $section = $arrangementSection->section;
$label = $section?->label;
if ($label === null) { if ($section === null || $label === null) {
continue; continue;
} }
if (in_array($label->id, $seenLabelIds, true)) { if (in_array($section->id, $seenSectionIds, true)) {
continue; continue;
} }
$seenLabelIds[] = $label->id; $seenSectionIds[] = $section->id;
$slides = []; $slides = [];
$labelSlides = $label->songSlides->sortBy('order')->values(); $sectionSlides = $section->slides->sortBy('order')->values();
$totalSlides = $labelSlides->count(); $totalSlides = $sectionSlides->count();
foreach ($labelSlides as $slideIndex => $slide) { foreach ($sectionSlides as $slideIndex => $slide) {
$slideData = ['text' => $slide->text_content ?? '']; $slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) { if ($slide->text_content_translated) {
@ -121,10 +121,11 @@ private function backgroundData(?Service $service): ?array
} }
return [ return [
'path' => Storage::disk('public')->path($background), 'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG', 'format' => 'JPG',
'width' => 1920, 'width' => 1920,
'height' => 1080, 'height' => 1080,
'bundleRelative' => true,
]; ];
} }
@ -142,11 +143,11 @@ private function buildArrangements(Song $song): array
$arrangements = []; $arrangements = [];
foreach ($song->arrangements as $arrangement) { foreach ($song->arrangements as $arrangement) {
$arrangement->loadMissing('arrangementLabels.label'); $arrangement->loadMissing('arrangementSections.section.label');
$groupNames = $arrangement->arrangementLabels $groupNames = $arrangement->arrangementSections
->sortBy('order') ->sortBy('order')
->map(fn ($al) => $al->label?->name) ->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
->filter() ->filter()
->values() ->values()
->toArray(); ->toArray();

View file

@ -6,6 +6,7 @@
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Support\MacroColorConverter; use App\Support\MacroColorConverter;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -104,14 +105,14 @@ private function upsertSong(ProSong $proSong): Song
} }
$song->arrangements()->each(function (SongArrangement $arr) { $song->arrangements()->each(function (SongArrangement $arr) {
$arr->arrangementLabels()->delete(); $arr->arrangementSections()->delete();
}); });
$song->arrangements()->delete(); $song->arrangements()->delete();
$hasTranslation = false; $hasTranslation = false;
$labelsByName = []; $sectionsByName = [];
foreach ($proSong->getGroups() as $proGroup) { foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
$groupName = $proGroup->getName(); $groupName = $proGroup->getName();
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first(); $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) { foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
$translatedText = null; $translatedText = null;
@ -137,7 +143,7 @@ private function upsertSong(ProSong $proSong): Song
$hasTranslation = true; $hasTranslation = true;
} }
$existingLabel->songSlides()->create([ $section->slides()->create([
'order' => $slidePosition, 'order' => $slidePosition,
'text_content' => $proSlide->getPlainText(), 'text_content' => $proSlide->getPlainText(),
'text_content_translated' => $translatedText, 'text_content_translated' => $translatedText,
@ -156,19 +162,19 @@ private function upsertSong(ProSong $proSong): Song
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement); $groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
foreach ($groupsInArrangement as $order => $proGroup) { foreach ($groupsInArrangement as $order => $proGroup) {
$label = $labelsByName[$proGroup->getName()] ?? null; $section = $sectionsByName[$proGroup->getName()] ?? null;
if ($label) { if ($section) {
SongArrangementLabel::create([ SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => $order, '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 public static function rgbaToHex(array $rgba): string

View file

@ -8,6 +8,18 @@
class ServiceImageResolver 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 public function keyVisualFor(Service $service): ?string
{ {
return $this->resolve($service->key_visual_filename, 'current_key_visual'); return $this->resolve($service->key_visual_filename, 'current_key_visual');

View file

@ -6,15 +6,16 @@
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class SongService class SongService
{ {
/** /**
* Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren. * Sicherstellen, dass die Default-Labels und Song-Sektionen existieren.
* *
* @return Collection<int, Label> * @return Collection<int, SongSection>
*/ */
public function createDefaultGroups(Song $song): Collection public function createDefaultGroups(Song $song): Collection
{ {
@ -24,9 +25,9 @@ public function createDefaultGroups(Song $song): Collection
['name' => 'Bridge', 'color' => '#F59E0B'], ['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(); $existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
if ($existing === null) { 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, 'is_default' => true,
]); ]);
$labels = $this->createDefaultGroups($song); $sections = $this->createDefaultGroups($song);
foreach ($labels->values() as $index => $label) { foreach ($sections->values() as $index => $section) {
$arrangement->arrangementLabels()->create([ $arrangement->arrangementSections()->create([
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => $index + 1, '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->is_default = false;
$clone->save(); $clone->save();
foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) { foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
SongArrangementLabel::create([ SongArrangementLabel::create([
'song_arrangement_id' => $clone->id, 'song_arrangement_id' => $clone->id,
'label_id' => $arrangementLabel->label_id, 'song_section_id' => $arrangementSection->song_section_id,
'order' => $arrangementLabel->order, 'order' => $arrangementSection->order,
]); ]);
} }
return $clone->load('arrangementLabels.label'); return $clone->load('arrangementSections.section.label');
}); });
} }
} }

View file

@ -34,7 +34,7 @@ public function importTranslation(Song $song, string $text): void
$defaultArr = $song->arrangements() $defaultArr = $song->arrangements()
->where('is_default', true) ->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(); ->first();
if ($defaultArr === null) { if ($defaultArr === null) {
@ -43,14 +43,14 @@ public function importTranslation(Song $song, string $text): void
return; return;
} }
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) { foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$label = $arrangementLabel->label; $section = $arrangementSection->section;
if ($label === null) { if ($section === null) {
continue; continue;
} }
foreach ($label->songSlides->sortBy('order') as $slide) { foreach ($section->slides->sortBy('order') as $slide) {
$originalLineCount = count(explode("\n", $slide->text_content ?? '')); $originalLineCount = count(explode("\n", $slide->text_content ?? ''));
$chunk = array_slice($translatedLines, $offset, $originalLineCount); $chunk = array_slice($translatedLines, $offset, $originalLineCount);
$offset += $originalLineCount; $offset += $originalLineCount;
@ -71,15 +71,13 @@ public function markAsTranslated(Song $song): void
public function removeTranslation(Song $song): void public function removeTranslation(Song $song): void
{ {
$labelIds = $song->arrangements() $sectionIds = $song->sections()
->with('arrangementLabels') ->pluck('id')
->get()
->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
->unique() ->unique()
->values(); ->values();
if ($labelIds->isNotEmpty()) { if ($sectionIds->isNotEmpty()) {
SongSlide::whereIn('label_id', $labelIds)->update([ SongSlide::whereIn('song_section_id', $sectionIds)->update([
'text_content_translated' => null, 'text_content_translated' => null,
]); ]);
} }

View file

@ -7,7 +7,7 @@ final class CcliLabels
/** /**
* Regex matching CCLI SongSelect section labels (English + German + variants). * 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). * Regex matching CCLI footer/metadata lines (copyright, CCLI number).
@ -18,6 +18,7 @@ final class CcliLabels
* Bidirectional English German label kind mapping. * Bidirectional English German label kind mapping.
*/ */
public const LABEL_NAME_MAP = [ public const LABEL_NAME_MAP = [
'Vers' => 'Verse',
'Strophe' => 'Verse', 'Strophe' => 'Verse',
'Refrain' => 'Chorus', 'Refrain' => 'Chorus',
'Brücke' => 'Bridge', 'Brücke' => 'Bridge',
@ -53,7 +54,7 @@ public static function normalizeLabelName(string $label): string
{ {
$trimmed = trim($label); $trimmed = trim($label);
if (! preg_match('/^(?<kind>Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?<suffix>\s+\d+[a-z]?)?$/iu', $trimmed, $matches)) { if (! preg_match('/^(?<kind>Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?<suffix>\s+\d+[a-z]?)?$/iu', $trimmed, $matches)) {
return $trimmed; return $trimmed;
} }
@ -70,7 +71,7 @@ public static function parseLabel(string $line): ?array
{ {
$trimmed = trim($line); $trimmed = trim($line);
if (! preg_match('/^(?<kind>Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?:\s+(?<number>\d+[a-z]?))?(?:\s*\((?<modifier>Repeat|Wdh\.?)\))?(?:\s*[xX]\s*\d+)?$/iu', $trimmed, $matches)) { if (! preg_match('/^(?<kind>Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?:\s+(?<number>\d+[a-z]?))?(?:\s*\((?<modifier>Repeat|Wdh\.?)\))?(?:\s*[xX]\s*\d+)?$/iu', $trimmed, $matches)) {
return null; return null;
} }

4
database/factories/SongArrangementLabelFactory.php Normal file → Executable file
View file

@ -2,9 +2,9 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Label;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
class SongArrangementLabelFactory extends Factory class SongArrangementLabelFactory extends Factory
@ -15,7 +15,7 @@ public function definition(): array
{ {
return [ return [
'song_arrangement_id' => SongArrangement::factory(), 'song_arrangement_id' => SongArrangement::factory(),
'label_id' => Label::factory(), 'song_section_id' => SongSection::factory(),
'order' => $this->faker->numberBetween(0, 10), 'order' => $this->faker->numberBetween(0, 10),
]; ];
} }

View file

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\SongArrangement;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use Illuminate\Database\Eloquent\Factories\Factory;
class SongArrangementSectionFactory extends Factory
{
protected $model = SongArrangementSection::class;
public function definition(): array
{
return [
'song_arrangement_id' => SongArrangement::factory(),
'song_section_id' => SongSection::factory(),
'order' => $this->faker->numberBetween(0, 10),
];
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongSection;
use Illuminate\Database\Eloquent\Factories\Factory;
class SongSectionFactory extends Factory
{
protected $model = SongSection::class;
public function definition(): array
{
return [
'song_id' => Song::factory(),
'label_id' => Label::factory(),
'order' => $this->faker->numberBetween(0, 10),
];
}
}

4
database/factories/SongSlideFactory.php Normal file → Executable file
View file

@ -2,7 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Label; use App\Models\SongSection;
use App\Models\SongSlide; use App\Models\SongSlide;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -13,7 +13,7 @@ class SongSlideFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'label_id' => Label::factory(), 'song_section_id' => SongSection::factory(),
'order' => $this->faker->numberBetween(1, 12), 'order' => $this->faker->numberBetween(1, 12),
'text_content' => implode("\n", $this->faker->sentences(3)), 'text_content' => implode("\n", $this->faker->sentences(3)),
'text_content_translated' => $this->faker->optional()->sentence(), 'text_content_translated' => $this->faker->optional()->sentence(),

View file

@ -0,0 +1,246 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('song_sections', function (Blueprint $table): void {
$table->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();
}
};

520
package-lock.json generated
View file

@ -28,9 +28,9 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -38,9 +38,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -48,13 +48,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.3", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.29.0" "@babel/types": "^7.29.7"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@ -64,14 +64,14 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5" "@babel/helper-validator-identifier": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -520,9 +520,9 @@
} }
}, },
"node_modules/@inertiajs/core": { "node_modules/@inertiajs/core": {
"version": "2.3.23", "version": "2.3.24",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.23.tgz", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.24.tgz",
"integrity": "sha512-d+jcdf91RY5bjT+ivBn2gu9Qsmxx5CacvVmVp7o9H2Lu/k1cBD0m4UZB5YKWfG11aBCucShBxuppEXme7sSEow==", "integrity": "sha512-xAlUl5+RKtdbutEgsmdWa6HmnvjIGcWTrvfLj/3Icy3/7bSH3aiI+kuYPs17LBq/SMaXnqBZXXo094rEXUv2aA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -534,13 +534,13 @@
} }
}, },
"node_modules/@inertiajs/vue3": { "node_modules/@inertiajs/vue3": {
"version": "2.3.23", "version": "2.3.24",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.23.tgz", "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.24.tgz",
"integrity": "sha512-W6rhjNCIKQQYZsNFdlFEa+prATbXcIR5k9VMg3PsYfRmqTYTORZspdrTH7JLPMneLuhNiI/Y6VbFR0TxGdv5Fg==", "integrity": "sha512-TokM+JU88YTHClh/LcKk31qiIAZFq3RQ4BBf1dxvk6MV45KWYemJMpLS6WFJ5NaSv6rZFlZrRc92N0ZdyOC/HA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inertiajs/core": "2.3.23", "@inertiajs/core": "2.3.24",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"laravel-precognition": "^1.0.2", "laravel-precognition": "^1.0.2",
"lodash-es": "^4.18.1" "lodash-es": "^4.18.1"
@ -617,13 +617,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.59.1", "version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.59.1" "playwright": "1.60.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -633,16 +633,16 @@
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.13", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -654,9 +654,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -668,9 +668,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -682,9 +682,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -696,9 +696,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -710,9 +710,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -724,9 +724,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -738,9 +738,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -752,9 +752,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -766,9 +766,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -780,9 +780,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -794,9 +794,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -808,9 +808,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -822,9 +822,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -836,9 +836,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -850,9 +850,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -864,9 +864,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -878,9 +878,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -892,9 +892,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -906,9 +906,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -920,9 +920,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -934,9 +934,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -948,9 +948,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -962,9 +962,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -976,9 +976,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1313,13 +1313,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.6", "version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.13" "@rolldown/pluginutils": "^1.0.1"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@ -1330,111 +1330,111 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.3", "@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34", "@vue/shared": "3.5.35",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.34", "@vue/compiler-core": "3.5.35",
"@vue/shared": "3.5.34" "@vue/shared": "3.5.35"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.3", "@babel/parser": "^7.29.3",
"@vue/compiler-core": "3.5.34", "@vue/compiler-core": "3.5.35",
"@vue/compiler-dom": "3.5.34", "@vue/compiler-dom": "3.5.35",
"@vue/compiler-ssr": "3.5.34", "@vue/compiler-ssr": "3.5.35",
"@vue/shared": "3.5.34", "@vue/shared": "3.5.35",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.14", "postcss": "^8.5.15",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.34", "@vue/compiler-dom": "3.5.35",
"@vue/shared": "3.5.34" "@vue/shared": "3.5.35"
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.34" "@vue/shared": "3.5.35"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.34", "@vue/reactivity": "3.5.35",
"@vue/shared": "3.5.34" "@vue/shared": "3.5.35"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.34", "@vue/reactivity": "3.5.35",
"@vue/runtime-core": "3.5.34", "@vue/runtime-core": "3.5.35",
"@vue/shared": "3.5.34", "@vue/shared": "3.5.35",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.34", "@vue/compiler-ssr": "3.5.35",
"@vue/shared": "3.5.34" "@vue/shared": "3.5.35"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.34" "vue": "3.5.35"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1477,6 +1477,19 @@
"url": "https://github.com/sponsors/antfu" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@ -1548,21 +1561,22 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.16.0", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.29", "version": "2.10.33",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -1638,9 +1652,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001792", "version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1768,6 +1782,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1804,9 +1836,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.353", "version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -1818,9 +1850,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.21.2", "version": "5.22.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
"integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1865,9 +1897,9 @@
} }
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2156,9 +2188,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.3", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2168,6 +2200,20 @@
"node": ">= 0.4" "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": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "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" "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": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@ -2560,11 +2613,14 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.38", "version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=18"
}
}, },
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
@ -2600,13 +2656,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.59.1", "version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.59.1" "playwright-core": "1.60.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -2619,9 +2675,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.59.1", "version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -2632,9 +2688,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2652,7 +2708,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -2678,9 +2734,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.1", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@ -2716,9 +2772,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.3", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2732,31 +2788,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm-eabi": "4.60.4",
"@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-android-arm64": "4.60.4",
"@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.4",
"@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.4",
"@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.4",
"@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
"@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
"@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.4",
"@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.4",
"@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.4",
"@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.4",
"@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
"@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.4",
"@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
"@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.4",
"@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.4",
"@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.4",
"@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.4",
"@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.4",
"@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.4",
"@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.4",
"@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.4",
"@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.4",
"@rollup/rollup-win32-x64-msvc": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.4",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -2935,9 +2991,9 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3114,17 +3170,17 @@
} }
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.34", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.34", "@vue/compiler-dom": "3.5.35",
"@vue/compiler-sfc": "3.5.34", "@vue/compiler-sfc": "3.5.35",
"@vue/runtime-dom": "3.5.34", "@vue/runtime-dom": "3.5.35",
"@vue/server-renderer": "3.5.34", "@vue/server-renderer": "3.5.35",
"@vue/shared": "3.5.34" "@vue/shared": "3.5.35"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View file

@ -166,7 +166,7 @@ function saveArrangement() {
`/arrangements/${selectedArrangement.value.id}`, `/arrangements/${selectedArrangement.value.id}`,
{ {
groups: arrangementGroups.value.map((group, index) => ({ groups: arrangementGroups.value.map((group, index) => ({
label_id: group.id, section_id: group.section_id ?? group.id,
order: index + 1, order: index + 1,
})), })),
}, },

View file

@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3' import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue' import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
import SongEditModal from '@/Components/SongEditModal.vue'
const MASTER_ID = 'master' const MASTER_ID = 'master'
@ -44,6 +45,7 @@ const selectedSongId = ref('')
const dropdownOpen = ref(false) const dropdownOpen = ref(false)
const assignError = ref('') const assignError = ref('')
const ccliDialogOpen = ref(false) const ccliDialogOpen = ref(false)
const editSongId = ref(null)
function normalize(value) { function normalize(value) {
return (value ?? '').toString().toLowerCase().trim() return (value ?? '').toString().toLowerCase().trim()
@ -552,7 +554,7 @@ function closeOnBackdrop(e) {
</button> </button>
<a <a
v-if="searchQuery" v-if="searchQuery"
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(searchQuery)" :href="'https://songselect.ccli.com/search/results?search=' + encodeURIComponent(searchQuery)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
data-testid="songselect-search-button" data-testid="songselect-search-button"
@ -765,6 +767,15 @@ function closeOnBackdrop(e) {
:service-song-id="props.serviceSongId" :service-song-id="props.serviceSongId"
@close="ccliDialogOpen = false" @close="ccliDialogOpen = false"
@imported="(songId) => { ccliDialogOpen = false; router.reload({ only: ['service'] }) }" @imported="(songId) => { ccliDialogOpen = false; router.reload({ only: ['service'] }) }"
@edit-song="(id) => { ccliDialogOpen = false; editSongId = id }"
/>
<!-- Song Edit Modal -->
<SongEditModal
:show="editSongId !== null"
:song-id="editSongId"
@close="editSongId = null"
@updated="() => router.reload({ only: ['service'] })"
/> />
</template> </template>

View file

@ -10,7 +10,7 @@ const props = defineProps({
prefilledText: { type: String, default: null }, prefilledText: { type: String, default: null },
}) })
const emit = defineEmits(['close', 'imported', 'paired']) const emit = defineEmits(['close', 'imported', 'paired', 'edit-song'])
const pasteText = ref('') const pasteText = ref('')
const preview = ref(null) const preview = ref(null)
@ -94,7 +94,8 @@ async function doImport(importMode) {
} }
if (importMode === 'edit') { if (importMode === 'edit') {
router.visit('/songs/' + data.song_id) emit('edit-song', data.song_id)
emit('close')
} else if (importMode === 'pair') { } else if (importMode === 'pair') {
router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true') router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true')
} else { } else {
@ -110,7 +111,8 @@ async function doImport(importMode) {
</script> </script>
<template> <template>
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <Teleport to="body">
<div v-if="open" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 p-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@ -131,8 +133,8 @@ async function doImport(importMode) {
<!-- Instructions --> <!-- Instructions -->
<ol class="text-sm text-gray-600 mb-4 space-y-1 list-decimal list-inside"> <ol class="text-sm text-gray-600 mb-4 space-y-1 list-decimal list-inside">
<li>Öffne die Liedseite auf <strong>songselect.ccli.com</strong></li> <li>Öffne die Liedseite auf <strong>songselect.ccli.com</strong></li>
<li>Markiere alles (<kbd class="px-1 py-0.5 bg-gray-100 rounded text-xs">Strg+A</kbd>) und kopiere (<kbd class="px-1 py-0.5 bg-gray-100 rounded text-xs">Strg+C</kbd>)</li> <li>Klicke das <strong>Kopier-Symbol</strong> neben dem Liedtext es kopiert Titel, Liedtext und CCLI-Infos</li>
<li>Füge den Text unten ein und klicke <strong>Vorschau</strong></li> <li>Füge alles unten ein und klicke auf <strong>Vorschau"</strong></li>
</ol> </ol>
<!-- Textarea --> <!-- Textarea -->
@ -168,12 +170,13 @@ async function doImport(importMode) {
class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700"
> >
{{ error }} {{ error }}
<a <button
v-if="existingSongId" v-if="existingSongId"
:href="'/songs/' + existingSongId" type="button"
@click="emit('edit-song', existingSongId); emit('close')"
data-testid="ccli-existing-song-link" data-testid="ccli-existing-song-link"
class="ml-2 underline font-medium" class="ml-2 underline font-medium"
>Vorhandenen Song bearbeiten</a> >Vorhandenen Song bearbeiten</button>
</div> </div>
<!-- Preview pane --> <!-- Preview pane -->
@ -184,8 +187,21 @@ async function doImport(importMode) {
<div><span class="text-gray-500">CCLI-Nr.:</span> {{ preview.ccliId || '' }}</div> <div><span class="text-gray-500">CCLI-Nr.:</span> {{ preview.ccliId || '' }}</div>
<div><span class="text-gray-500">Jahr:</span> {{ preview.year || '' }}</div> <div><span class="text-gray-500">Jahr:</span> {{ preview.year || '' }}</div>
</div> </div>
<div class="text-xs text-gray-500"> <div class="max-h-72 overflow-auto space-y-3">
Sektionen: {{ preview.sections?.map(s => s.label).join(', ') }} <div
v-for="(section, si) in preview.sections"
:key="si"
data-testid="ccli-preview-section"
>
<div class="flex items-center gap-2">
<h4 class="text-xs font-semibold text-gray-700">{{ section.label }}</h4>
<span
v-if="section.hasTranslation"
class="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700"
>mit Übersetzung</span>
</div>
<p class="mt-0.5 whitespace-pre-wrap text-xs text-gray-500">{{ section.lines?.join('\n') }}</p>
</div>
</div> </div>
</div> </div>
@ -245,4 +261,5 @@ async function doImport(importMode) {
</div> </div>
</div> </div>
</div> </div>
</Teleport>
</template> </template>

View file

@ -21,17 +21,77 @@ const emit = defineEmits(['close', 'updated'])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const songData = ref(null) const songData = ref(null)
const sectionDrafts = ref({})
const title = ref('') const title = ref('')
const ccliId = ref('') const ccliId = ref('')
const copyrightText = ref('') const copyrightText = ref('')
const showAddSectionForm = ref(false)
const newSectionLabel = ref('')
const newSectionText = ref('')
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
let savedTimeout = null 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 ── */ /* ── 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 () => { const fetchSong = async () => {
if (!props.songId) return if (!props.songId) return
@ -53,6 +113,7 @@ const fetchSong = async () => {
const json = await response.json() const json = await response.json()
songData.value = json.data songData.value = json.data
setSectionDrafts(json.data)
title.value = json.data.title ?? '' title.value = json.data.title ?? ''
ccliId.value = json.data.ccli_id ?? '' ccliId.value = json.data.ccli_id ?? ''
@ -73,7 +134,11 @@ watch(
if (!isVisible) { if (!isVisible) {
songData.value = null songData.value = null
sectionDrafts.value = {}
error.value = null error.value = null
showAddSectionForm.value = false
newSectionLabel.value = ''
newSectionText.value = ''
} }
}, },
) )
@ -83,8 +148,7 @@ watch(
const performSave = async (data) => { const performSave = async (data) => {
if (!props.songId) return if (!props.songId) return
saving.value = true startSaving()
saved.value = false
try { try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
@ -105,17 +169,11 @@ const performSave = async (data) => {
throw new Error('Speichern fehlgeschlagen') throw new Error('Speichern fehlgeschlagen')
} }
saving.value = false finishSaving()
saved.value = true
if (savedTimeout) clearTimeout(savedTimeout)
savedTimeout = setTimeout(() => {
saved.value = false
}, 2000)
emit('updated') emit('updated')
} catch { } catch {
saving.value = false stopSaving()
} }
} }
@ -150,10 +208,12 @@ const arrangements = computed(() => {
name: arr.name, name: arr.name,
is_default: arr.is_default, is_default: arr.is_default,
groups: arr.arrangement_groups.map((ag) => { 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 { 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', name: group?.name ?? 'Unbekannt',
color: group?.color ?? '#6b7280', color: group?.color ?? '#6b7280',
order: ag.order, order: ag.order,
@ -167,11 +227,161 @@ const availableGroups = computed(() => {
return songData.value.groups.map((group) => ({ return songData.value.groups.map((group) => ({
id: group.id, id: group.id,
section_id: group.section_id ?? group.id,
label_id: group.label_id,
name: group.name, name: group.name,
color: group.color, 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 ── */ /* ── Close handling ── */
const closeOnEscape = (e) => { const closeOnEscape = (e) => {
@ -481,6 +691,155 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Section editing -->
<div class="border-b border-gray-100 px-6 py-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Sektionen
</h3>
<p class="mt-1 text-sm text-gray-500">
Leerzeilen trennen einzelne Folien. Änderungen speichern automatisch.
</p>
</div>
<button
data-testid="section-add-button"
type="button"
class="rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600"
@click="showAddSectionForm = !showAddSectionForm"
>
Neue Sektion
</button>
</div>
<form
v-if="showAddSectionForm"
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4"
@submit.prevent="addSection"
>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label for="section-add-label" class="mb-1 block text-sm font-medium text-gray-700">
Name
</label>
<input
data-testid="section-add-label-input"
id="section-add-label"
v-model="newSectionLabel"
list="section-label-options"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="z.B. Strophe 3"
required
>
<datalist id="section-label-options">
<option
v-for="labelName in sectionLabelOptions"
:key="labelName"
:value="labelName"
/>
</datalist>
</div>
<div class="sm:col-span-2">
<label for="section-add-text" class="mb-1 block text-sm font-medium text-gray-700">
Text
</label>
<textarea
data-testid="section-add-text-input"
id="section-add-text"
v-model="newSectionText"
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext eingeben…"
/>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-white/70"
@click="showAddSectionForm = false"
>
Abbrechen
</button>
<button
data-testid="section-add-submit"
type="submit"
class="rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-gray-800"
>
Hinzufügen
</button>
</div>
</form>
<div class="space-y-4">
<div
v-for="group in songData.groups"
:key="group.section_id ?? group.id"
data-testid="section-block"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
>
<div class="mb-3 flex items-center justify-between gap-3">
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white shadow-sm"
:style="{ backgroundColor: group.color ?? '#6b7280' }"
>
{{ group.name }}
</span>
<button
data-testid="section-delete-button"
type="button"
class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700"
title="Sektion löschen"
@click="deleteSection(group.section_id ?? group.id)"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7h6m2 0H7m3-3h4a1 1 0 011 1v2H9V5a1 1 0 011-1z" />
</svg>
</button>
</div>
<div :class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700">
Originaltext
</label>
<textarea
data-testid="section-text-input"
v-model="draftFor(group).text"
rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext…"
@input="onSectionInput(group.section_id ?? group.id)"
@blur="onSectionBlur(group.section_id ?? group.id)"
/>
</div>
<div v-if="songData.has_translation">
<label class="mb-1 block text-sm font-medium text-gray-700">
Übersetzung
</label>
<textarea
data-testid="section-translated-input"
v-model="draftFor(group).translated"
rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Übersetzter Folientext…"
@input="onSectionInput(group.section_id ?? group.id)"
@blur="onSectionBlur(group.section_id ?? group.id)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Arrangement Configurator --> <!-- Arrangement Configurator -->
<div class="px-6 py-5"> <div class="px-6 py-5">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500"> <h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">

View file

@ -218,7 +218,7 @@ async function updateSetting(key, value) {
<details class="mt-4"> <details class="mt-4">
<summary class="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Was tun, wenn der Liedtext nicht erkannt wird?</summary> <summary class="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Was tun, wenn der Liedtext nicht erkannt wird?</summary>
<p class="mt-2 text-xs text-blue-700"> <p class="mt-2 text-xs text-blue-700">
Falle zurück auf das manuelle Einfügen: Öffne das Lied auf SongSelect, drücke <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+A</kbd> dann <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+C</kbd>, und klicke dann auf Aus CCLI importieren" in der Song-Datenbank oder im Gottesdienst-Formular. Falle zurück auf das manuelle Einfügen: Öffne das Lied auf SongSelect, klicke das <strong>Kopier-Symbol</strong> neben dem Liedtext (es kopiert Titel, Liedtext und CCLI-Infos), und klicke dann auf Aus CCLI importieren" in der Song-Datenbank oder im Gottesdienst-Formular.
</p> </p>
</details> </details>
</div> </div>

View file

@ -1,7 +1,9 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue' import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
import SongEditModal from '@/Components/SongEditModal.vue'
import { Head, router } from '@inertiajs/vue3' import { Head, router } from '@inertiajs/vue3'
import { ref } from 'vue'
const props = defineProps({ const props = defineProps({
prefilledText: { type: String, default: null }, prefilledText: { type: String, default: null },
@ -9,6 +11,8 @@ const props = defineProps({
prefillError: { type: String, default: null }, prefillError: { type: String, default: null },
}) })
const editSongId = ref(null)
function handleClose() { function handleClose() {
router.visit(route('songs.index')) router.visit(route('songs.index'))
} }
@ -49,6 +53,13 @@ function handleImported(songId, mode) {
:prefilled-text="prefilledText" :prefilled-text="prefilledText"
@close="handleClose" @close="handleClose"
@imported="handleImported" @imported="handleImported"
@edit-song="(id) => { editSongId = id }"
/>
<SongEditModal
:show="editSongId !== null"
:song-id="editSongId"
@close="() => router.visit(route('songs.index'))"
/> />
</div> </div>
</div> </div>

View file

@ -393,7 +393,7 @@ function pageRange() {
<div class="mb-4 flex items-center gap-2"> <div class="mb-4 flex items-center gap-2">
<a <a
v-if="search" v-if="search"
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(search)" :href="'https://songselect.ccli.com/search/results?search=' + encodeURIComponent(search)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
data-testid="songselect-search-button-songdb" data-testid="songselect-search-button-songdb"
@ -789,5 +789,6 @@ function pageRange() {
mode="songdb" mode="songdb"
@close="ccliDialogOpen = false" @close="ccliDialogOpen = false"
@imported="(songId, mode) => { ccliDialogOpen = false; if (mode === 'stay') { router.reload({ only: ['songs'] }) } }" @imported="(songId, mode) => { ccliDialogOpen = false; if (mode === 'stay') { router.reload({ only: ['songs'] }) } }"
@edit-song="(id) => { ccliDialogOpen = false; openEditModal({ id }) }"
/> />
</template> </template>

View file

@ -12,6 +12,7 @@
use App\Http\Controllers\ServiceMacroOverrideController; use App\Http\Controllers\ServiceMacroOverrideController;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\SongPdfController; use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\SongSectionController;
use App\Http\Controllers\SyncController; use App\Http\Controllers\SyncController;
use App\Http\Controllers\TranslationController; use App\Http\Controllers\TranslationController;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -90,6 +91,9 @@
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone'); Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update'); Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy'); Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy');
Route::post('/songs/{song}/sections', [SongSectionController::class, 'store'])->name('songs.sections.store');
Route::patch('/songs/{song}/sections/{section}', [SongSectionController::class, 'update'])->name('songs.sections.update');
Route::delete('/songs/{song}/sections/{section}', [SongSectionController::class, 'destroy'])->name('songs.sections.destroy');
Route::get('/songs/{song}/arrangements/{arrangement}/pdf', [SongPdfController::class, 'download'])->name('songs.pdf'); Route::get('/songs/{song}/arrangements/{arrangement}/pdf', [SongPdfController::class, 'download'])->name('songs.pdf');
Route::get('/songs/{song}/arrangements/{arrangement}/preview', [SongPdfController::class, 'preview'])->name('songs.preview'); Route::get('/songs/{song}/arrangements/{arrangement}/preview', [SongPdfController::class, 'preview'])->name('songs.preview');

View file

@ -105,7 +105,8 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
$song = Song::factory()->create(['title' => 'Amazing Grace']); $song = Song::factory()->create(['title' => 'Amazing Grace']);
$label = Label::factory()->create(['name' => 'Verse 1']); $label = Label::factory()->create(['name' => 'Verse 1']);
SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]); $section = songSectionFor($song, $label);
SongSlide::factory()->create(['song_section_id' => $section->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
$arrangement = SongArrangement::factory()->create([ $arrangement = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
@ -113,7 +114,7 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
]); ]);

View file

@ -6,6 +6,7 @@
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -37,13 +38,13 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
$defaultLabelOrder = SongArrangementLabel::query() $defaultLabelOrder = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id) ->where('song_arrangement_id', $normal->id)
->orderBy('order') ->orderBy('order')
->pluck('label_id') ->pluck('song_section_id')
->all(); ->all();
$newLabels = SongArrangementLabel::query() $newLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $newArrangement->id) ->where('song_arrangement_id', $newArrangement->id)
->orderBy('order') ->orderBy('order')
->pluck('label_id') ->pluck('song_section_id')
->all(); ->all();
$this->assertSame($defaultLabelOrder, $newLabels); $this->assertSame($defaultLabelOrder, $newLabels);
@ -72,13 +73,13 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups():
$originalLabels = SongArrangementLabel::query() $originalLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id) ->where('song_arrangement_id', $normal->id)
->orderBy('order') ->orderBy('order')
->pluck('label_id') ->pluck('song_section_id')
->all(); ->all();
$cloneLabels = SongArrangementLabel::query() $cloneLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $clone->id) ->where('song_arrangement_id', $clone->id)
->orderBy('order') ->orderBy('order')
->pluck('label_id') ->pluck('song_section_id')
->all(); ->all();
$this->assertSame($originalLabels, $cloneLabels); $this->assertSame($originalLabels, $cloneLabels);
@ -92,10 +93,10 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
$response = $this->put(route('arrangements.update', $normal), [ $response = $this->put(route('arrangements.update', $normal), [
'groups' => [ 'groups' => [
['label_id' => $chorus->id, 'order' => 1], ['section_id' => $chorus->id, 'order' => 1],
['label_id' => $bridge->id, 'order' => 2], ['section_id' => $bridge->id, 'order' => 2],
['label_id' => $verse->id, 'order' => 3], ['section_id' => $verse->id, 'order' => 3],
['label_id' => $chorus->id, 'order' => 4], ['section_id' => $chorus->id, 'order' => 4],
], ],
]); ]);
@ -104,7 +105,7 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
$updated = SongArrangementLabel::query() $updated = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id) ->where('song_arrangement_id', $normal->id)
->orderBy('order') ->orderBy('order')
->pluck('label_id') ->pluck('song_section_id')
->all(); ->all();
$this->assertSame([ $this->assertSame([
@ -136,9 +137,13 @@ private function createSongWithDefaultArrangement(): array
{ {
$song = Song::factory()->create(); $song = Song::factory()->create();
$verse = Label::factory()->create(['name' => 'Verse 1']); $verseLabel = Label::factory()->create(['name' => 'Verse 1']);
$chorus = Label::factory()->create(['name' => 'Chorus']); $chorusLabel = Label::factory()->create(['name' => 'Chorus']);
$bridge = Label::factory()->create(['name' => 'Bridge']); $bridgeLabel = Label::factory()->create(['name' => 'Bridge']);
$verse = SongSection::factory()->create(['song_id' => $song->id, 'label_id' => $verseLabel->id, 'order' => 1]);
$chorus = SongSection::factory()->create(['song_id' => $song->id, 'label_id' => $chorusLabel->id, 'order' => 2]);
$bridge = SongSection::factory()->create(['song_id' => $song->id, 'label_id' => $bridgeLabel->id, 'order' => 3]);
$normal = SongArrangement::factory()->create([ $normal = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
@ -148,19 +153,19 @@ private function createSongWithDefaultArrangement(): array
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'label_id' => $verse->id, 'song_section_id' => $verse->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'label_id' => $chorus->id, 'song_section_id' => $chorus->id,
'order' => 2, 'order' => 2,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'label_id' => $verse->id, 'song_section_id' => $verse->id,
'order' => 3, 'order' => 3,
]); ]);

View file

@ -1,41 +1,65 @@
<?php <?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(RefreshDatabase::class); final class BookmarkletControllerTest extends TestCase
{
use RefreshDatabase;
test('bookmarklet endpoint returns 200 with text/javascript content type', function () { public function test_bookmarklet_endpoint_returns_200_with_text_javascript_content_type(): void
{
$response = $this->get('/bookmarklets/ccli-import.js'); $response = $this->get('/bookmarklets/ccli-import.js');
$response->assertStatus(200); $response->assertStatus(200);
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8'); $response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
}); }
test('bookmarklet response starts with javascript: prefix', function () { public function test_bookmarklet_response_starts_with_javascript_prefix(): void
{
$response = $this->get('/bookmarklets/ccli-import.js'); $response = $this->get('/bookmarklets/ccli-import.js');
expect($response->getContent())->toStartWith('javascript:'); expect($response->getContent())->toStartWith('javascript:');
}); }
test('bookmarklet response is a single line with no actual newlines', function () { public function test_bookmarklet_response_is_a_single_line_with_no_actual_newlines(): void
{
$response = $this->get('/bookmarklets/ccli-import.js'); $response = $this->get('/bookmarklets/ccli-import.js');
$content = $response->getContent(); $content = (string) $response->getContent();
expect(substr_count($content, "\n"))->toBe(0); expect(substr_count($content, "\n"))->toBe(0);
}); }
test('bookmarklet response contains app URL and import path', function () { public function test_bookmarklet_response_contains_app_url_and_import_path(): void
{
$response = $this->get('/bookmarklets/ccli-import.js'); $response = $this->get('/bookmarklets/ccli-import.js');
$content = $response->getContent(); $content = $response->getContent();
expect($content)->toContain('import-from-ccli-paste'); expect($content)->toContain('import-from-ccli-paste');
expect($content)->toContain('songselect.ccli.com'); expect($content)->toContain('songselect.ccli.com');
expect($content)->toContain('btoa'); expect($content)->toContain('btoa');
}); }
test('bookmarklet endpoint does not require authentication', function () { public function test_bookmarklet_response_uses_request_host_instead_of_configured_app_url(): void
{
config(['app.url' => 'http://pp-planer.test']);
$response = $this
->withServerVariables(['HTTP_HOST' => 'pp-planer.ddev.site', 'HTTPS' => 'on'])
->get('/bookmarklets/ccli-import.js');
$content = $response->getContent();
expect($content)->toContain('pp-planer.ddev.site')
->not->toContain('pp-planer.test');
}
public function test_bookmarklet_endpoint_does_not_require_authentication(): void
{
$response = $this->get('/bookmarklets/ccli-import.js'); $response = $this->get('/bookmarklets/ccli-import.js');
$response->assertStatus(200); $response->assertStatus(200);
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8'); $response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
}); }
}

View file

@ -2,6 +2,7 @@
use App\Exceptions\DuplicateCcliSongException; use App\Exceptions\DuplicateCcliSongException;
use App\Models\ApiRequestLog; use App\Models\ApiRequestLog;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSlide; use App\Models\SongSlide;
@ -76,6 +77,39 @@ function ccliFixture(string $name): string
expect(Song::count())->toBe(1); expect(Song::count())->toBe(1);
}); });
test('fills existing empty ccli song instead of blocking as duplicate', function () {
$emptySong = Song::factory()->create([
'ccli_id' => '4327499',
'title' => 'ChurchTools Platzhalter',
'author' => null,
]);
$result = app(CcliImportService::class)->import(ccliFixture('copy-icon-vers-author-trailing.txt'));
$song = $result['song']->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
$arrangement = $song->arrangements->first();
expect($result['status'])->toBe('restored')
->and($song->id)->toBe($emptySong->id)
->and($song->title)->toBe('Heilig ist der Herr')
->and($song->author)->toBe('Albert Frey')
->and($arrangement)->not->toBeNull()
->and($arrangement->arrangementSections)->toHaveCount(2)
->and(SongSlide::count())->toBe(12);
});
test('uses distinct label colors for imported section kinds', function () {
app(CcliImportService::class)->import(ccliFixture('copy-icon-vers-author-trailing.txt'));
$verse = Label::where('name', 'Verse')->first();
$chorus = Label::where('name', 'Chorus')->first();
expect($verse)->not->toBeNull()
->and($chorus)->not->toBeNull()
->and($verse->color)->toBe('#3B82F6')
->and($chorus->color)->toBe('#10B981')
->and($verse->color)->not->toBe($chorus->color);
});
test('restores soft-deleted song and does not duplicate normal arrangement', function () { test('restores soft-deleted song and does not duplicate normal arrangement', function () {
$service = app(CcliImportService::class); $service = app(CcliImportService::class);
$first = $service->import(ccliFixture('english-only-multi-verse.txt')); $first = $service->import(ccliFixture('english-only-multi-verse.txt'));

View file

@ -74,6 +74,28 @@ function ccliFixtureContent(string $filename): string
expect($kinds)->toContain('Chorus'); expect($kinds)->toContain('Chorus');
}); });
test('copy-icon-vers-author-trailing.txt parses SongSelect copy icon format', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('copy-icon-vers-author-trailing.txt'));
expect($result->title)->toBe('Heilig ist der Herr')
->and($result->author)->toBe('Albert Frey')
->and($result->ccliId)->toBe('4327499')
->and($result->year)->toBe('1998')
->and($result->sections)->toHaveCount(2);
$verse = $result->sections[0];
$chorus = $result->sections[1];
expect($verse->label)->toBe('Vers')
->and($verse->kind)->toBe('Verse')
->and($verse->lines)->toHaveCount(9)
->and($chorus->label)->toBe('Chorus')
->and($chorus->kind)->toBe('Chorus')
->and($chorus->lines)->toHaveCount(3)
->and($chorus->lines)->not->toContain('Albert Frey');
});
test('common CCLI metadata formats extract the song ID but not license numbers', function (): void { test('common CCLI metadata formats extract the song ID but not license numbers', function (): void {
$parser = new CcliPasteParser; $parser = new CcliPasteParser;

View file

@ -4,6 +4,7 @@
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Models\SongSlide; use App\Models\SongSlide;
use App\Services\CcliTranslationPairingService; use App\Services\CcliTranslationPairingService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -26,16 +27,21 @@ function makeLocalSongForCcliPairing(array $labelConfig): Song
['name' => $labelName], ['name' => $labelName],
['color' => '#3B82F6'], ['color' => '#3B82F6'],
); );
$section = SongSection::create([
'song_id' => $song->id,
'label_id' => $label->id,
'order' => $order + 1,
]);
SongArrangementLabel::create([ SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => $order + 1, 'order' => $order + 1,
]); ]);
for ($i = 0; $i < $slideCount; $i++) { for ($i = 0; $i < $slideCount; $i++) {
SongSlide::create([ SongSlide::create([
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => $i + 1, 'order' => $i + 1,
'text_content' => "Original line $i for $labelName", 'text_content' => "Original line $i for $labelName",
]); ]);

View file

@ -118,12 +118,15 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
$this->assertNotEmpty($songSlides); $this->assertNotEmpty($songSlides);
foreach ($songSlides as $slide) { foreach ($songSlides as $slide) {
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media'); $this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media');
$this->assertSame( $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
Storage::disk('public')->path('slides/bg.jpg'),
$slide->getBackgroundMediaUrl(),
);
} }
$embeddedMedia = $playlist->getEmbeddedMediaFiles();
$this->assertArrayHasKey('BACKGROUND.jpg', $embeddedMedia, 'Background must be embedded under fixed name');
$this->assertSame('background-image', $embeddedMedia['BACKGROUND.jpg']);
$this->assertArrayHasKey('KEY_VISUAL.jpg', $embeddedMedia, 'Key-visual must be embedded under fixed name');
$this->assertSame('keyvisual-image', $embeddedMedia['KEY_VISUAL.jpg']);
$sermonParser = $playlist->getEmbeddedSong('Predigt.pro'); $sermonParser = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertNotNull($sermonParser, 'Embedded sermon .pro missing'); $this->assertNotNull($sermonParser, 'Embedded sermon .pro missing');
$sermonSlides = $this->allParserSlides($sermonParser); $sermonSlides = $this->allParserSlides($sermonParser);
@ -259,10 +262,11 @@ private function createSongWithContent(string $title): Song
['name' => 'Verse 1 - '.$title], ['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'], ['color' => '#2196F3'],
); );
$label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]); $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song; return $song;
} }

View file

@ -57,7 +57,9 @@ public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisua
$slides = $this->allParserSlides($fallbackSong); $slides = $this->allParserSlides($fallbackSong);
$this->assertCount(1, $slides); $this->assertCount(1, $slides);
$this->assertTrue($slides[0]->hasBackgroundMedia()); $this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertSame(Storage::disk('public')->path('slides/keyvisual.jpg'), $slides[0]->getBackgroundMediaUrl()); $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$this->assertSame('keyvisual-image', $playlist->getEmbeddedMediaFiles()['KEY_VISUAL.jpg']);
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
} }
@ -200,10 +202,11 @@ private function createSongWithContent(string $title): Song
['name' => 'Verse 1 - '.$title], ['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'], ['color' => '#2196F3'],
); );
$label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]); $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song; return $song;
} }

View file

@ -2,18 +2,25 @@
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
test('song_slides has label_id column after migration', function () { test('song_slides belongs to song_sections after migration', function () {
expect(Schema::hasColumn('song_slides', 'label_id'))->toBeTrue(); expect(Schema::hasColumn('song_slides', 'song_section_id'))->toBeTrue();
expect(Schema::hasColumn('song_slides', 'label_id'))->toBeFalse();
expect(Schema::hasColumn('song_slides', 'song_group_id'))->toBeFalse(); expect(Schema::hasColumn('song_slides', 'song_group_id'))->toBeFalse();
}); });
test('song_sections table exists with expected columns', function () {
expect(Schema::hasTable('song_sections'))->toBeTrue();
expect(Schema::hasColumns('song_sections', ['id', 'song_id', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
});
test('song_arrangement_groups table is dropped', function () { test('song_arrangement_groups table is dropped', function () {
expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse(); expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse();
}); });
test('song_arrangement_labels table exists with expected columns', function () { test('song_arrangement_labels table exists with expected columns', function () {
expect(Schema::hasTable('song_arrangement_labels'))->toBeTrue(); expect(Schema::hasTable('song_arrangement_labels'))->toBeTrue();
expect(Schema::hasColumns('song_arrangement_labels', ['id', 'song_arrangement_id', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue(); expect(Schema::hasColumns('song_arrangement_labels', ['id', 'song_arrangement_id', 'song_section_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
expect(Schema::hasColumn('song_arrangement_labels', 'label_id'))->toBeFalse();
}); });
test('song_groups table is dropped', function () { test('song_groups table is dropped', function () {

View file

@ -40,17 +40,19 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
['name' => 'Verse 1 - '.$title], ['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'], ['color' => '#2196F3'],
); );
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']); $verseSection = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
$verseSection->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$chorus = Label::firstOrCreate( $chorus = Label::firstOrCreate(
['name' => 'Chorus - '.$title], ['name' => 'Chorus - '.$title],
['color' => '#F44336'], ['color' => '#F44336'],
); );
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']); $chorusSection = $song->sections()->create(['label_id' => $chorus->id, 'order' => 1]);
$chorusSection->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]); $arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]); $arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
return $song; return $song;
} }

View file

@ -54,19 +54,22 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
$playlist = ProPlaylistReader::read($result['path']); $playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries(); $entries = $playlist->getEntries();
$this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], $this->entryNames($playlist)); $names = $this->entryNames($playlist);
$offset = $names[0] === 'Moderator' ? 1 : 0;
$this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], array_slice($names, $offset));
$keyVisualSlides = $this->slidesForEntry($playlist, $entries[0]); $keyVisualSlides = $this->slidesForEntry($playlist, $entries[$offset]);
$this->assertCount(1, $keyVisualSlides); $this->assertCount(1, $keyVisualSlides);
$this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia()); $this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia());
$this->assertSame(Storage::disk('public')->path('slides/keyvisual.jpg'), $keyVisualSlides[0]->getBackgroundMediaUrl()); $this->assertSame('KEY_VISUAL.jpg', $keyVisualSlides[0]->getBackgroundMediaUrl());
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$nameTagSlides = $this->slidesForEntry($playlist, $entries[1]); $nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
$this->assertCount(1, $nameTagSlides); $this->assertCount(1, $nameTagSlides);
$this->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText()); $this->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText());
$this->assertTrue($nameTagSlides[0]->hasMacro()); $this->assertTrue($nameTagSlides[0]->hasMacro());
$sermonSlides = $this->slidesForEntry($playlist, $entries[2]); $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
$this->assertCount(2, $sermonSlides); $this->assertCount(2, $sermonSlides);
$this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel()); $this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel());
$this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel()); $this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel());
@ -226,10 +229,11 @@ private function createSongWithContent(string $title): Song
['name' => 'Verse 1 - '.$title], ['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'], ['color' => '#2196F3'],
); );
$label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]); $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song; return $song;
} }

View file

@ -39,19 +39,21 @@ private function createSongWithContent(): Song
['name' => 'Verse 1 - Export Test Song'], ['name' => 'Verse 1 - Export Test Song'],
['color' => '#2196F3'], ['color' => '#2196F3'],
); );
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']); $verseSection = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']); $verseSection->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verseSection->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$chorus = Label::firstOrCreate( $chorus = Label::firstOrCreate(
['name' => 'Chorus - Export Test Song'], ['name' => 'Chorus - Export Test Song'],
['color' => '#F44336'], ['color' => '#F44336'],
); );
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']); $chorusSection = $song->sections()->create(['label_id' => $chorus->id, 'order' => 1]);
$chorusSection->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]); $arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]); $arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]); $arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 2]);
return $song; return $song;
} }
@ -120,13 +122,13 @@ public function test_download_pro_roundtrip_preserves_content(): void
$importResponse->assertOk(); $importResponse->assertOk();
$songId = $importResponse->json('songs.0.id'); $songId = $importResponse->json('songs.0.id');
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId); $originalSong = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($songId);
$this->assertNotNull($originalSong); $this->assertNotNull($originalSong);
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first(); $defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
$this->assertNotNull($defaultArr); $this->assertNotNull($defaultArr);
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values(); $originalArrangementSections = $defaultArr->arrangementSections->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements; $originalArrangements = $originalSong->arrangements;
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
@ -144,21 +146,21 @@ public function test_download_pro_roundtrip_preserves_content(): void
$reImportedGroups = $reImported->getGroups(); $reImportedGroups = $reImported->getGroups();
$uniqueOriginalLabels = $originalArrangementLabels $uniqueOriginalSections = $originalArrangementSections
->map(fn ($al) => $al->label) ->map(fn ($arrangementSection) => $arrangementSection->section)
->filter() ->filter()
->unique('id') ->unique('id')
->values(); ->values();
$this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch'); $this->assertCount($uniqueOriginalSections->count(), $reImportedGroups, 'Group count mismatch');
foreach ($uniqueOriginalLabels as $index => $originalLabel) { foreach ($uniqueOriginalSections as $index => $originalSection) {
$reImportedGroup = $reImportedGroups[$index]; $reImportedGroup = $reImportedGroups[$index];
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}"); $this->assertSame($originalSection->label->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
$originalSlides = $originalLabel->songSlides->sortBy('order')->values(); $originalSlides = $originalSection->slides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup); $reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'"); $this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalSection->label->name}'");
foreach ($originalSlides as $slideIndex => $originalSlide) { foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex]; $reImportedSlide = $reImportedSlides[$slideIndex];
@ -166,15 +168,15 @@ public function test_download_pro_roundtrip_preserves_content(): void
$this->assertSame( $this->assertSame(
$originalSlide->text_content, $originalSlide->text_content,
$reImportedSlide->getPlainText(), $reImportedSlide->getPlainText(),
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}" "Slide text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
); );
if ($originalSlide->text_content_translated) { if ($originalSlide->text_content_translated) {
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}"); $this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalSection->label->name}' slide {$slideIndex}");
$this->assertSame( $this->assertSame(
$originalSlide->text_content_translated, $originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(), $reImportedSlide->getTranslation()?->getPlainText(),
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}" "Translation text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
); );
} }
} }
@ -187,9 +189,9 @@ public function test_download_pro_roundtrip_preserves_content(): void
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name); $reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import"); $this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementLabels $originalGroupNames = $originalArrangement->arrangementSections
->sortBy('order') ->sortBy('order')
->map(fn ($al) => $al->label?->name) ->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
->filter() ->filter()
->values() ->values()
->toArray(); ->toArray();
@ -284,7 +286,6 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
'background_filename' => 'slides/background.jpg', 'background_filename' => 'slides/background.jpg',
]); ]);
$song = $this->createSongWithContent(); $song = $this->createSongWithContent();
$expectedPath = Storage::disk('public')->path('slides/background.jpg');
$parserSong = app(ProExportService::class)->generateParserSong($song, $service); $parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong); $slides = $this->allParserSlides($parserSong);
@ -292,7 +293,7 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
$this->assertNotEmpty($slides); $this->assertNotEmpty($slides);
foreach ($slides as $slide) { foreach ($slides as $slide) {
$this->assertTrue($slide->hasBackgroundMedia()); $this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame($expectedPath, $slide->getBackgroundMediaUrl()); $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
$this->assertSame('JPG', $slide->getBackgroundMediaFormat()); $this->assertSame('JPG', $slide->getBackgroundMediaFormat());
} }
} }
@ -340,11 +341,15 @@ public function test_sermon_export_ueberspringt_background_bei_full_cover_folien
]); ]);
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon'); $bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon');
$slides = $this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()); $bundle = ProBundleReader::read($bundlePath);
$slides = $this->allParserSlides($bundle->getSong());
$this->assertCount(2, $slides); $this->assertCount(2, $slides);
$this->assertTrue($slides[0]->hasBackgroundMedia()); $this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
$this->assertFalse($slides[1]->hasBackgroundMedia()); $this->assertFalse($slides[1]->hasBackgroundMedia());
$this->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name');
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
@unlink($bundlePath); @unlink($bundlePath);
} }

View file

@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Song; use App\Models\Song;
use App\Models\SongSection;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -72,8 +73,9 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void
'is_default' => true, 'is_default' => true,
]); ]);
$oldLabel = \App\Models\Label::firstOrCreate(['name' => 'Old Group'], ['color' => '#FF0000']); $oldLabel = \App\Models\Label::firstOrCreate(['name' => 'Old Group'], ['color' => '#FF0000']);
$oldSection = SongSection::factory()->create(['song_id' => $existingSong->id, 'label_id' => $oldLabel->id]);
$arrangement->arrangementLabels()->create([ $arrangement->arrangementLabels()->create([
'label_id' => $oldLabel->id, 'song_section_id' => $oldSection->id,
'order' => 0, 'order' => 0,
]); ]);

View file

@ -42,13 +42,8 @@
}); });
test('migration rolls back cleanly', function (): void { test('migration rolls back cleanly', function (): void {
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Destruktive Migration');
Artisan::call('migrate:rollback', ['--step' => 1]); Artisan::call('migrate:rollback', ['--step' => 1]);
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeFalse();
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeFalse();
Artisan::call('migrate');
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeTrue();
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeTrue();
}); });

View file

@ -84,8 +84,8 @@
$arrangement = $song->arrangements()->where('is_default', true)->first(); $arrangement = $song->arrangements()->where('is_default', true)->first();
expect($arrangement)->not->toBeNull(); expect($arrangement)->not->toBeNull();
expect($arrangement->name)->toBe('Normal'); expect($arrangement->name)->toBe('Normal');
expect($arrangement->arrangementLabels)->toHaveCount(3); expect($arrangement->arrangementSections)->toHaveCount(3);
expect($arrangement->arrangementLabels->sortBy('order')->pluck('label.name')->toArray()) expect($arrangement->arrangementSections->sortBy('order')->pluck('section.label.name')->toArray())
->toBe(['Strophe 1', 'Refrain', 'Bridge']); ->toBe(['Strophe 1', 'Refrain', 'Bridge']);
}); });
@ -125,10 +125,11 @@
test('show returns song with groups slides and arrangements', function () { test('show returns song with groups slides and arrangements', function () {
$song = Song::factory()->create(); $song = Song::factory()->create();
$label = Label::factory()->create(['name' => 'Strophe 1']); $label = Label::factory()->create(['name' => 'Strophe 1']);
$section = songSectionFor($song, $label);
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]); $arrangement = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
]); ]);
@ -261,6 +262,8 @@
$song = Song::factory()->create(); $song = Song::factory()->create();
$label1 = Label::factory()->create(); $label1 = Label::factory()->create();
$label2 = Label::factory()->create(); $label2 = Label::factory()->create();
$section1 = songSectionFor($song, $label1, 1);
$section2 = songSectionFor($song, $label2, 2);
$arrangement = SongArrangement::factory()->create([ $arrangement = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
@ -269,12 +272,12 @@
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label1->id, 'song_section_id' => $section1->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label2->id, 'song_section_id' => $section2->id,
'order' => 2, 'order' => 2,
]); ]);
@ -283,7 +286,7 @@
expect($clone->name)->toBe('Klone'); expect($clone->name)->toBe('Klone');
expect($clone->is_default)->toBeFalse(); expect($clone->is_default)->toBeFalse();
expect($clone->arrangementLabels)->toHaveCount(2); expect($clone->arrangementSections)->toHaveCount(2);
expect($clone->arrangementLabels->pluck('label_id')->toArray()) expect($clone->arrangementSections->pluck('song_section_id')->toArray())
->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray()); ->toBe($arrangement->arrangementSections->pluck('song_section_id')->toArray());
}); });

View file

@ -26,6 +26,8 @@
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#10B981', 'color' => '#10B981',
]); ]);
$section1 = songSectionFor($song, $label1, 1);
$section2 = songSectionFor($song, $label2, 2);
$arrangement = SongArrangement::factory()->create([ $arrangement = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
@ -35,13 +37,13 @@
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label1->id, 'song_section_id' => $section1->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label2->id, 'song_section_id' => $section2->id,
'order' => 2, 'order' => 2,
]); ]);

View file

@ -22,16 +22,17 @@
'name' => 'Verse 1', 'name' => 'Verse 1',
'color' => '#3B82F6', 'color' => '#3B82F6',
]); ]);
$section = songSectionFor($song, $label);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
'text_content' => 'Amazing grace how sweet the sound', 'text_content' => 'Amazing grace how sweet the sound',
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
]); ]);
@ -74,28 +75,30 @@
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#10B981', 'color' => '#10B981',
]); ]);
$verseSection = songSectionFor($song, $verse, 1);
$chorusSection = songSectionFor($song, $chorus, 2);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $verse->id, 'song_section_id' => $verseSection->id,
'order' => 1, 'order' => 1,
'text_content' => 'Großer Gott wir loben dich', 'text_content' => 'Großer Gott wir loben dich',
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $chorus->id, 'song_section_id' => $chorusSection->id,
'order' => 1, 'order' => 1,
'text_content' => 'Heilig heilig heilig', 'text_content' => 'Heilig heilig heilig',
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $verse->id, 'song_section_id' => $verseSection->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $chorus->id, 'song_section_id' => $chorusSection->id,
'order' => 2, 'order' => 2,
]); ]);
@ -120,9 +123,10 @@
$label = Label::factory()->create([ $label = Label::factory()->create([
'name' => 'Verse 1', 'name' => 'Verse 1',
]); ]);
$section = songSectionFor($song, $label);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
'text_content' => 'Amazing grace how sweet the sound', 'text_content' => 'Amazing grace how sweet the sound',
'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang', 'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang',
@ -130,7 +134,7 @@
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
]); ]);
@ -198,16 +202,17 @@
$label = Label::factory()->create([ $label = Label::factory()->create([
'name' => 'Strophe 1', 'name' => 'Strophe 1',
]); ]);
$section = songSectionFor($song, $label);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
'text_content' => 'Großer Gott wir loben dich', 'text_content' => 'Großer Gott wir loben dich',
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
]); ]);
@ -251,15 +256,17 @@
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#ef4444', 'color' => '#ef4444',
]); ]);
$verseSection = songSectionFor($song, $verse, 1);
$chorusSection = songSectionFor($song, $chorus, 2);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $verse->id, 'song_section_id' => $verseSection->id,
'order' => 1, 'order' => 1,
'text_content' => 'Strophe Text', 'text_content' => 'Strophe Text',
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $chorus->id, 'song_section_id' => $chorusSection->id,
'order' => 1, 'order' => 1,
'text_content' => 'Refrain Text', 'text_content' => 'Refrain Text',
]); ]);
@ -271,13 +278,13 @@
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $chorus->id, 'song_section_id' => $chorusSection->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $verse->id, 'song_section_id' => $verseSection->id,
'order' => 2, 'order' => 2,
]); ]);
@ -310,9 +317,10 @@
$label = Label::factory()->create([ $label = Label::factory()->create([
'name' => 'Verse', 'name' => 'Verse',
]); ]);
$section = songSectionFor($song, $label);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
'text_content' => 'Original Text', 'text_content' => 'Original Text',
'text_content_translated' => 'Translated Text', 'text_content_translated' => 'Translated Text',
@ -324,7 +332,7 @@
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => 1, 'order' => 1,
]); ]);

View file

@ -0,0 +1,191 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Models\SongSlide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SongSectionControllerTest extends TestCase
{
use RefreshDatabase;
public function test_patch_edits_only_target_song_section(): void
{
$this->actingAs(User::factory()->create());
$label = Label::factory()->create(['name' => 'Chorus']);
[$songA, $sectionA] = $this->createSongWithSection($label, ['Alter Refrain']);
[, $sectionB] = $this->createSongWithSection($label, ['Anderer Refrain']);
$response = $this->patchJson(route('songs.sections.update', [$songA, $sectionA]), [
'slides' => [
['text_content' => 'Neuer Refrain', 'text_content_translated' => 'New chorus'],
['text_content' => 'Zweiter Block', 'text_content_translated' => null],
],
]);
$response->assertOk()
->assertJsonPath('data.has_translation', true)
->assertJsonPath('data.groups.0.slides.0.text_content', 'Neuer Refrain');
$this->assertSame(['Neuer Refrain', 'Zweiter Block'], $sectionA->slides()->orderBy('order')->pluck('text_content')->all());
$this->assertSame(['Anderer Refrain'], $sectionB->slides()->orderBy('order')->pluck('text_content')->all());
$this->assertTrue($songA->fresh()->has_translation);
}
public function test_post_adds_section_reuses_normalized_label_and_appends_default_arrangement(): void
{
$this->actingAs(User::factory()->create());
$song = Song::factory()->create(['has_translation' => false]);
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Normal',
'is_default' => true,
]);
$existingLabel = Label::factory()->create(['name' => 'Verse 3', 'color' => '#111111']);
$response = $this->postJson(route('songs.sections.store', $song), [
'label_name' => 'Strophe 3',
'color' => '#ABCDEF',
'slides' => [
['text_content' => 'Neue Strophe', 'text_content_translated' => null],
],
]);
$response->assertCreated()
->assertJsonPath('data.groups.0.name', 'Verse 3')
->assertJsonPath('data.groups.0.slides.0.text_content', 'Neue Strophe');
$this->assertSame(1, Label::query()->where('name', 'Verse 3')->count());
$section = SongSection::query()->where('song_id', $song->id)->where('label_id', $existingLabel->id)->first();
$this->assertNotNull($section);
$this->assertDatabaseHas('song_slides', [
'song_section_id' => $section->id,
'text_content' => 'Neue Strophe',
]);
$this->assertDatabaseHas('song_arrangement_labels', [
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 1,
]);
}
public function test_delete_removes_section_slides_and_junction_for_this_song_only_but_keeps_global_label(): void
{
$this->actingAs(User::factory()->create());
$label = Label::factory()->create(['name' => 'Bridge']);
[$songA, $sectionA, $arrangementA] = $this->createSongWithSection($label, ['Bridge A']);
[, $sectionB] = $this->createSongWithSection($label, ['Bridge B']);
$slideId = $sectionA->slides()->first()->id;
$junctionId = SongArrangementLabel::query()
->where('song_arrangement_id', $arrangementA->id)
->where('song_section_id', $sectionA->id)
->value('id');
$response = $this->deleteJson(route('songs.sections.destroy', [$songA, $sectionA]));
$response->assertOk()
->assertJsonPath('data.groups', []);
$this->assertDatabaseMissing('song_sections', ['id' => $sectionA->id]);
$this->assertDatabaseMissing('song_slides', ['id' => $slideId]);
$this->assertDatabaseMissing('song_arrangement_labels', ['id' => $junctionId]);
$this->assertDatabaseHas('labels', ['id' => $label->id]);
$this->assertDatabaseHas('song_sections', ['id' => $sectionB->id]);
$this->assertSame(['Bridge B'], $sectionB->slides()->pluck('text_content')->all());
}
public function test_validation_and_ownership_errors_are_german(): void
{
$this->actingAs(User::factory()->create());
$label = Label::factory()->create(['name' => 'Verse 1']);
[$songA, $sectionA] = $this->createSongWithSection($label, ['Song A']);
[$songB] = $this->createSongWithSection(Label::factory()->create(['name' => 'Chorus']), ['Song B']);
$this->patchJson(route('songs.sections.update', [$songB, $sectionA]), [
'slides' => [
['text_content' => 'Falsch'],
],
])->assertNotFound()
->assertJsonPath('message', 'Sektion nicht gefunden.');
$this->postJson(route('songs.sections.store', $songA), [
'slides' => [
['text_content' => 'Ohne Label'],
],
])->assertUnprocessable()
->assertJsonPath('message', 'Bitte gib einen Namen für die Sektion ein.');
$this->postJson(route('songs.sections.store', $songA), [
'label_name' => 'Verse 1',
'slides' => [
['text_content' => 'Doppelt'],
],
])->assertUnprocessable()
->assertJsonPath('message', 'Dieser Abschnitt existiert bereits in diesem Lied.');
}
public function test_has_translation_is_recomputed_after_edits(): void
{
$this->actingAs(User::factory()->create());
[$song, $section] = $this->createSongWithSection(Label::factory()->create(['name' => 'Verse 1']), ['Original']);
$this->patchJson(route('songs.sections.update', [$song, $section]), [
'slides' => [
['text_content' => 'Original', 'text_content_translated' => 'Übersetzt'],
],
])->assertOk()
->assertJsonPath('data.has_translation', true);
$this->assertTrue($song->fresh()->has_translation);
$this->patchJson(route('songs.sections.update', [$song, $section]), [
'slides' => [
['text_content' => 'Original', 'text_content_translated' => ''],
],
])->assertOk()
->assertJsonPath('data.has_translation', false);
$this->assertFalse($song->fresh()->has_translation);
}
private function createSongWithSection(Label $label, array $slides): array
{
$song = Song::factory()->create(['has_translation' => false]);
$section = SongSection::factory()->create([
'song_id' => $song->id,
'label_id' => $label->id,
'order' => 1,
]);
foreach ($slides as $index => $text) {
SongSlide::factory()->create([
'song_section_id' => $section->id,
'order' => $index + 1,
'text_content' => $text,
'text_content_translated' => null,
]);
}
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Normal',
'is_default' => true,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 1,
]);
return [$song, $section, $arrangement];
}
}

View file

@ -88,6 +88,8 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#10B981', 'color' => '#10B981',
]); ]);
$verseSection = songSectionFor($song, $verse, 1);
$chorusSection = songSectionFor($song, $chorus, 2);
$normal = SongArrangement::factory()->create([ $normal = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
@ -97,13 +99,13 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'label_id' => $verse->id, 'song_section_id' => $verseSection->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'label_id' => $chorus->id, 'song_section_id' => $chorusSection->id,
'order' => 2, 'order' => 2,
]); ]);

View file

@ -38,35 +38,37 @@ public function test_translate_page_response_contains_ordered_groups_and_slides(
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'color' => '#0ea5e9', 'color' => '#0ea5e9',
]); ]);
$sectionFirst = songSectionFor($song, $labelFirst, 1);
$sectionLater = songSectionFor($song, $labelLater, 2);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $labelFirst->id, 'song_section_id' => $sectionFirst->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $labelLater->id, 'song_section_id' => $sectionLater->id,
'order' => 2, 'order' => 2,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $labelFirst->id, 'song_section_id' => $sectionFirst->id,
'order' => 2, 'order' => 2,
'text_content' => "Zeile A\nZeile B", 'text_content' => "Zeile A\nZeile B",
'text_content_translated' => "Line A\nLine B", 'text_content_translated' => "Line A\nLine B",
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $labelFirst->id, 'song_section_id' => $sectionFirst->id,
'order' => 1, 'order' => 1,
'text_content' => "Zeile C\nZeile D\nZeile E", 'text_content' => "Zeile C\nZeile D\nZeile E",
'text_content_translated' => null, 'text_content_translated' => null,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $labelLater->id, 'song_section_id' => $sectionLater->id,
'order' => 1, 'order' => 1,
'text_content' => 'Refrain', 'text_content' => 'Refrain',
'text_content_translated' => 'Chorus', 'text_content_translated' => 'Chorus',

View file

@ -4,6 +4,7 @@
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel; use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Models\SongSlide; use App\Models\SongSlide;
use App\Models\User; use App\Models\User;
use App\Services\TranslationService; use App\Services\TranslationService;
@ -70,23 +71,24 @@ function makeSongWithDefaultArrangement(): array
return [$song, $arrangement]; return [$song, $arrangement];
} }
function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): Label function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): SongSection
{ {
$label = Label::firstOrCreate(['name' => $labelName]); $label = Label::firstOrCreate(['name' => $labelName]);
$section = SongSection::firstOrCreate(
['song_id' => $arrangement->song_id, 'label_id' => $label->id],
['order' => $arrangementOrder],
);
SongArrangementLabel::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id, 'song_section_id' => $section->id,
'order' => $arrangementOrder, 'order' => $arrangementOrder,
]); ]);
foreach ($slides as $slide) { foreach ($slides as $slide) {
SongSlide::factory()->create(array_merge( $section->slides()->create($slide);
['label_id' => $label->id],
$slide,
));
} }
return $label; return $section;
} }
test('importTranslation distributes lines by slide line counts', function () { test('importTranslation distributes lines by slide line counts', function () {
@ -95,19 +97,19 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4", 'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 2, 'order' => 2,
'text_content' => "Original 5\nOriginal 6", 'text_content' => "Original 5\nOriginal 6",
]); ]);
$slide3 = SongSlide::factory()->create([ $slide3 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 3, 'order' => 3,
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10", 'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
]); ]);
@ -132,13 +134,13 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2); $label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'label_id' => $label1->id, 'song_section_id' => $label1->id,
'order' => 1, 'order' => 1,
'text_content' => "Line A\nLine B", 'text_content' => "Line A\nLine B",
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'label_id' => $label2->id, 'song_section_id' => $label2->id,
'order' => 1, 'order' => 1,
'text_content' => "Line C\nLine D\nLine E", 'text_content' => "Line C\nLine D\nLine E",
]); ]);
@ -160,13 +162,13 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => "Line 1\nLine 2\nLine 3", 'text_content' => "Line 1\nLine 2\nLine 3",
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 2, 'order' => 2,
'text_content' => "Line 4\nLine 5", 'text_content' => "Line 4\nLine 5",
]); ]);
@ -187,7 +189,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Line 1', 'text_content' => 'Line 1',
]); ]);
@ -214,14 +216,14 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Original', 'text_content' => 'Original',
'text_content_translated' => 'Übersetzt', 'text_content_translated' => 'Übersetzt',
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 2, 'order' => 2,
'text_content' => 'Original 2', 'text_content' => 'Original 2',
'text_content_translated' => 'Übersetzt 2', 'text_content_translated' => 'Übersetzt 2',
@ -282,7 +284,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1);
$slide = SongSlide::factory()->create([ $slide = SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => "Line 1\nLine 2", 'text_content' => "Line 1\nLine 2",
]); ]);
@ -328,7 +330,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1);
SongSlide::factory()->create([ SongSlide::factory()->create([
'label_id' => $label->id, 'song_section_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Original', 'text_content' => 'Original',
'text_content_translated' => 'Übersetzt', 'text_content_translated' => 'Übersetzt',

View file

@ -41,7 +41,21 @@
| |
*/ */
function something() function songSectionFor(\App\Models\Song $song, \App\Models\Label $label, int $order = 1): \App\Models\SongSection
{ {
// .. return \App\Models\SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $label->id],
['order' => $order],
);
}
function addSongSlide(\App\Models\Song $song, \App\Models\Label $label, array $attributes = []): \App\Models\SongSlide
{
$section = songSectionFor($song, $label, $attributes['section_order'] ?? 1);
unset($attributes['section_order']);
return $section->slides()->create(array_merge([
'order' => 1,
'text_content' => 'Testzeile',
], $attributes));
} }

View file

@ -0,0 +1,23 @@
Heilig ist der Herr
Vers
Heilig, heilig, heilig ist der Herr.
Heilig, heilig, heilig ist der Herr.
Verzehrendes Feuer,
ewige Glut,
vollkommen gerecht und
vollkommen gut;
gewaltige Wasser,
endloses Meer,
unfassbar groß bist du, Herr.
Chorus
Voll Ehrfurcht stehen wir vor dir,
aus Gnade dürfen wir uns nahn,
voll Ehrfurcht stehn wir hier und beten an.
Albert Frey
CCLI-Liednummer 4327499
© 1998 FREYKLANG
Nutzung ausschließlich im Rahmen der SongSelect®-Nutzungsbedingungen. Alle Rechte vorbehalten. www.ccli.com
CCLI-Lizenznummer 1874681