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:
parent
e95abbc1e6
commit
ae42b48753
11
AGENTS.md
11
AGENTS.md
|
|
@ -152,6 +152,17 @@ ## KeyVisual & Background
|
|||
|
||||
When a service is finalized, the resolved filenames are snapshotted into the per-service columns so the export is stable even if the global default changes later.
|
||||
|
||||
### Export naming contract (portable bundles)
|
||||
|
||||
On export the key-visual and background images are **embedded into the archive** under fixed names and referenced **bundle-relative** inside the `.pro` file (never by absolute path), so exports are portable to the presenter PC:
|
||||
|
||||
| Image | Embedded filename + `.pro` reference |
|
||||
|-------|--------------------------------------|
|
||||
| Key-visual | `KEY_VISUAL.jpg` |
|
||||
| Background | `BACKGROUND.jpg` |
|
||||
|
||||
The fixed names are defined as `ServiceImageResolver::KEY_VISUAL_EXPORT_NAME` / `ServiceImageResolver::BACKGROUND_EXPORT_NAME`. The `slideData['background']` array carries `'path' => '<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
|
||||
|
||||
| File | Purpose |
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongSection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ArrangementController extends Controller
|
||||
{
|
||||
|
|
@ -29,18 +31,18 @@ public function store(Request $request, Song $song): RedirectResponse
|
|||
return;
|
||||
}
|
||||
|
||||
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
|
||||
$arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get();
|
||||
|
||||
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
|
||||
$rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $al->label_id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementLabels()->insert($rows);
|
||||
$arrangement->arrangementSections()->insert($rows);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -54,7 +56,7 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
|
|||
]);
|
||||
|
||||
DB::transaction(function () use ($arrangement, $data): void {
|
||||
$arrangement->loadMissing('arrangementLabels');
|
||||
$arrangement->loadMissing('arrangementSections');
|
||||
|
||||
$clone = $arrangement->song->arrangements()->create([
|
||||
'name' => $data['name'],
|
||||
|
|
@ -71,22 +73,23 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
{
|
||||
$data = $request->validate([
|
||||
'groups' => ['array'],
|
||||
'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'],
|
||||
'groups.*.section_id' => ['nullable', 'integer', 'exists:song_sections,id'],
|
||||
'groups.*.label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'groups.*.order' => ['required', 'integer', 'min:1'],
|
||||
'group_colors' => ['sometimes', 'array'],
|
||||
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
]);
|
||||
|
||||
$labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
|
||||
$sectionIds = $this->sectionIdsForGroups($arrangement, $data['groups'] ?? []);
|
||||
|
||||
DB::transaction(function () use ($arrangement, $labelIds, $data): void {
|
||||
$arrangement->arrangementLabels()->delete();
|
||||
DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
|
||||
$arrangement->arrangementSections()->delete();
|
||||
|
||||
$rows = $labelIds
|
||||
$rows = $sectionIds
|
||||
->values()
|
||||
->map(fn (int $labelId, int $index) => [
|
||||
->map(fn (int $sectionId, int $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $labelId,
|
||||
'song_section_id' => $sectionId,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
|
@ -94,12 +97,19 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementLabels()->insert($rows);
|
||||
$arrangement->arrangementSections()->insert($rows);
|
||||
}
|
||||
|
||||
if (! empty($data['group_colors'])) {
|
||||
foreach ($data['group_colors'] as $labelId => $color) {
|
||||
Label::whereKey((int) $labelId)->update(['color' => $color]);
|
||||
$sections = SongSection::whereIn('id', collect(array_keys($data['group_colors']))->map(fn ($id) => (int) $id))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($data['group_colors'] as $id => $color) {
|
||||
$section = $sections->get((int) $id);
|
||||
$labelId = $section?->label_id ?? (int) $id;
|
||||
|
||||
Label::whereKey($labelId)->update(['color' => $color]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -136,22 +146,56 @@ private function cloneArrangementLabels(?SongArrangement $source, SongArrangemen
|
|||
return;
|
||||
}
|
||||
|
||||
$arrangementLabels = $source->arrangementLabels
|
||||
$arrangementSections = $source->arrangementSections
|
||||
->sortBy('order')
|
||||
->values();
|
||||
|
||||
$rows = $arrangementLabels
|
||||
->map(fn ($arrangementLabel) => [
|
||||
$rows = $arrangementSections
|
||||
->map(fn ($arrangementSection) => [
|
||||
'song_arrangement_id' => $target->id,
|
||||
'label_id' => $arrangementLabel->label_id,
|
||||
'order' => $arrangementLabel->order,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $arrangementSection->order,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$target->arrangementLabels()->insert($rows);
|
||||
$target->arrangementSections()->insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
private function sectionIdsForGroups(SongArrangement $arrangement, array $groups): \Illuminate\Support\Collection
|
||||
{
|
||||
$songId = $arrangement->song_id;
|
||||
$sectionIds = collect($groups)->map(function (array $group) use ($songId) {
|
||||
if (isset($group['section_id'])) {
|
||||
$section = SongSection::find((int) $group['section_id']);
|
||||
|
||||
if ($section === null || (int) $section->song_id !== (int) $songId) {
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Diese Sektion gehört nicht zu diesem Song.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $section->id;
|
||||
}
|
||||
|
||||
if (isset($group['label_id'])) {
|
||||
$section = SongSection::where('song_id', $songId)
|
||||
->where('label_id', (int) $group['label_id'])
|
||||
->first();
|
||||
|
||||
if ($section !== null) {
|
||||
return $section->id;
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Bitte wähle gültige Song-Sektionen aus.',
|
||||
]);
|
||||
})->values();
|
||||
|
||||
return $sectionIds;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
final class BookmarkletController extends Controller
|
||||
{
|
||||
public function show(): Response
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$appUrl = rtrim($request->getSchemeAndHttpHost(), '/');
|
||||
|
||||
if ($appUrl === '') {
|
||||
$appUrl = rtrim((string) Config::get('app.url', ''), '/');
|
||||
}
|
||||
|
||||
$bookmarkletScript = <<<'BOOKMARKLET'
|
||||
(function(){
|
||||
|
|
@ -18,20 +23,42 @@ public function show(): Response
|
|||
alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).');
|
||||
return;
|
||||
}
|
||||
var title = (document.querySelector('h1, .song-title, [class*="title"]') || {}).innerText || document.title || '';
|
||||
var author = (document.querySelector('.song-authors, .song-artist, [class*="author"]') || {}).innerText || '';
|
||||
var bodyText = document.body ? document.body.innerText : '';
|
||||
var ccliMatch = bodyText.match(/CCLI[\s#-]*(\d+)/i);
|
||||
var ccliId = ccliMatch ? ccliMatch[1] : '';
|
||||
function send(text){
|
||||
var ccliMatch = (text || '').match(/CCLI[\s#-]*(\d+)/i);
|
||||
var payload = {
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
ccliId: ccliId,
|
||||
title: '',
|
||||
author: '',
|
||||
ccliId: ccliMatch ? ccliMatch[1] : '',
|
||||
sourceUrl: location.href,
|
||||
rawText: bodyText
|
||||
rawText: text || ''
|
||||
};
|
||||
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
|
||||
window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank');
|
||||
}
|
||||
var btn = document.querySelector('#generalCopyLyricsButton');
|
||||
if(!btn){
|
||||
alert('Kopier-Symbol nicht gefunden. Bitte öffne die Liedtext-Ansicht auf SongSelect und versuche es erneut.');
|
||||
return;
|
||||
}
|
||||
var captured = null;
|
||||
function onCopy(e){
|
||||
try { captured = e.clipboardData.getData('text/plain'); } catch(err) {}
|
||||
}
|
||||
document.addEventListener('copy', onCopy, true);
|
||||
btn.click();
|
||||
setTimeout(function(){
|
||||
document.removeEventListener('copy', onCopy, true);
|
||||
if(captured && captured.trim()){
|
||||
send(captured);
|
||||
return;
|
||||
}
|
||||
if(navigator.clipboard && navigator.clipboard.readText){
|
||||
navigator.clipboard.readText().then(function(text){ send(text); })
|
||||
.catch(function(){ alert('Liedtext konnte nicht aus der Zwischenablage gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".'); });
|
||||
} else {
|
||||
alert('Liedtext konnte nicht gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".');
|
||||
}
|
||||
}, 250);
|
||||
})();
|
||||
BOOKMARKLET;
|
||||
|
||||
|
|
|
|||
|
|
@ -131,13 +131,14 @@ public function edit(Service $service): Response
|
|||
$service->load([
|
||||
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
||||
'serviceSongs.song',
|
||||
'serviceSongs.song.arrangements.arrangementLabels.label',
|
||||
'serviceSongs.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSongs.arrangement',
|
||||
'slides',
|
||||
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'agendaItems.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'agendaItems.serviceSong.arrangement.arrangementLabels.label',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
|
||||
]);
|
||||
|
||||
$songsCatalog = Song::query()
|
||||
|
|
@ -295,13 +296,14 @@ public function edit(Service $service): Response
|
|||
'id' => $arrangement->id,
|
||||
'name' => $arrangement->name,
|
||||
'is_default' => $arrangement->is_default,
|
||||
'groups' => $arrangement->arrangementLabels
|
||||
'groups' => $arrangement->arrangementSections
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'id' => $arrangementLabel->label?->id,
|
||||
'name' => $arrangementLabel->label?->name,
|
||||
'color' => $arrangementLabel->label?->color,
|
||||
->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->section?->label?->id,
|
||||
'section_id' => $arrangementSection->section?->id,
|
||||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values(),
|
||||
|
|
@ -474,14 +476,15 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection
|
|||
return collect();
|
||||
}
|
||||
|
||||
return $defaultArr->arrangementLabels
|
||||
return $defaultArr->arrangementSections
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'id' => $arrangementLabel->label?->id,
|
||||
'name' => $arrangementLabel->label?->name,
|
||||
'color' => $arrangementLabel->label?->color,
|
||||
'order' => $arrangementLabel->order,
|
||||
->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->section?->label?->id,
|
||||
'section_id' => $arrangementSection->section?->id,
|
||||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
'order' => $arrangementSection->order,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values();
|
||||
|
|
|
|||
|
|
@ -62,13 +62,13 @@ public function store(SongRequest $request): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich erstellt',
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id);
|
||||
$song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
|
|
@ -91,7 +91,7 @@ public function update(SongRequest $request, int $id): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich aktualisiert',
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -110,22 +110,24 @@ public function destroy(int $id): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
private function formatSongDetail(Song $song): array
|
||||
public function formatSongDetail(Song $song): array
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true);
|
||||
|
||||
$groupsPayload = [];
|
||||
if ($defaultArr !== null) {
|
||||
$groupsPayload = $defaultArr->arrangementLabels
|
||||
$groupsPayload = $defaultArr->arrangementSections
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($al) => [
|
||||
'id' => $al->label?->id,
|
||||
'name' => $al->label?->name,
|
||||
'color' => $al->label?->color,
|
||||
'order' => $al->order,
|
||||
'slides' => $al->label
|
||||
? $al->label->songSlides
|
||||
->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->section?->id,
|
||||
'section_id' => $arrangementSection->section?->id,
|
||||
'label_id' => $arrangementSection->section?->label_id,
|
||||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
'order' => $arrangementSection->order,
|
||||
'slides' => $arrangementSection->section
|
||||
? $arrangementSection->section->slides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
|
|
@ -157,10 +159,11 @@ private function formatSongDetail(Song $song): array
|
|||
'id' => $arr->id,
|
||||
'name' => $arr->name,
|
||||
'is_default' => $arr->is_default,
|
||||
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
|
||||
'id' => $al->id,
|
||||
'label_id' => $al->label_id,
|
||||
'order' => $al->order,
|
||||
'arrangement_groups' => $arr->arrangementSections->sortBy('order')->values()->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->id,
|
||||
'section_id' => $arrangementSection->song_section_id,
|
||||
'label_id' => $arrangementSection->section?->label_id,
|
||||
'order' => $arrangementSection->order,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -57,21 +57,23 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
|
|||
private function buildGroupsInOrder(SongArrangement $arrangement): array
|
||||
{
|
||||
$arrangement->load([
|
||||
'arrangementLabels' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementSections' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementSections.section.slides' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementSections.section.label',
|
||||
]);
|
||||
|
||||
return $arrangement->arrangementLabels->map(function ($arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
return $arrangement->arrangementSections->map(function ($arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
||||
if ($label === null) {
|
||||
if ($section === null || $label === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $label->name,
|
||||
'color' => $label->color ?? '#6b7280',
|
||||
'slides' => $label->songSlides->map(fn ($slide) => [
|
||||
'slides' => $section->slides->map(fn ($slide) => [
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values()->all(),
|
||||
|
|
|
|||
174
app/Http/Controllers/SongSectionController.php
Normal file
174
app/Http/Controllers/SongSectionController.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -19,24 +19,27 @@ public function page(Song $song): Response
|
|||
{
|
||||
$song->load([
|
||||
'arrangements' => fn ($q) => $q->where('is_default', true),
|
||||
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'),
|
||||
'arrangements.arrangementLabels.label.songSlides',
|
||||
'arrangements.arrangementSections' => fn ($q) => $q->orderBy('order'),
|
||||
'arrangements.arrangementSections.section.slides',
|
||||
'arrangements.arrangementSections.section.label',
|
||||
]);
|
||||
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
$groups = collect();
|
||||
if ($defaultArr !== null) {
|
||||
$groups = $defaultArr->arrangementLabels
|
||||
$groups = $defaultArr->arrangementSections
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($al) => [
|
||||
'id' => $al->label?->id,
|
||||
'name' => $al->label?->name,
|
||||
'color' => $al->label?->color,
|
||||
'order' => $al->order,
|
||||
'slides' => $al->label
|
||||
? $al->label->songSlides
|
||||
->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->section?->id,
|
||||
'section_id' => $arrangementSection->section?->id,
|
||||
'label_id' => $arrangementSection->section?->label_id,
|
||||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
'order' => $arrangementSection->order,
|
||||
'slides' => $arrangementSection->section
|
||||
? $arrangementSection->section->slides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
|
|
|
|||
|
|
@ -25,16 +25,16 @@ protected function casts(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function songSlides(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSlide::class);
|
||||
}
|
||||
|
||||
public function macroAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MacroAssignment::class);
|
||||
}
|
||||
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSection::class);
|
||||
}
|
||||
|
||||
public function isHidden(): bool
|
||||
{
|
||||
return $this->hidden_at !== null;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ public function arrangements(): HasMany
|
|||
return $this->hasMany(SongArrangement::class);
|
||||
}
|
||||
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSection::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function serviceSongs(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceSong::class);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,12 @@ public function song(): BelongsTo
|
|||
|
||||
public function arrangementLabels(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementLabel::class)->orderBy('order');
|
||||
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
|
||||
}
|
||||
|
||||
public function arrangementSections(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
|
||||
}
|
||||
|
||||
public function serviceSongs(): HasMany
|
||||
|
|
|
|||
|
|
@ -2,27 +2,4 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SongArrangementLabel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_arrangement_id',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function arrangement(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
class SongArrangementLabel extends SongArrangementSection {}
|
||||
|
|
|
|||
30
app/Models/SongArrangementSection.php
Normal file
30
app/Models/SongArrangementSection.php
Normal 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');
|
||||
}
|
||||
}
|
||||
41
app/Models/SongSection.php
Normal file
41
app/Models/SongSection.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,15 @@ class SongSlide extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'label_id',
|
||||
'song_section_id',
|
||||
'order',
|
||||
'text_content',
|
||||
'text_content_translated',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function label(): BelongsTo
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
return $this->belongsTo(SongSection::class, 'song_section_id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSlide;
|
||||
use App\Models\SongSection;
|
||||
use App\Services\DTO\ParsedCcliSection;
|
||||
use App\Services\DTO\ParsedCcliSong;
|
||||
use App\Support\CcliLabels;
|
||||
|
|
@ -18,7 +18,18 @@
|
|||
|
||||
final class CcliImportService
|
||||
{
|
||||
private const DEFAULT_LABEL_COLOR = '#3B82F6';
|
||||
private const LABEL_KIND_COLORS = [
|
||||
'Verse' => '#3B82F6',
|
||||
'Chorus' => '#10B981',
|
||||
'Bridge' => '#F59E0B',
|
||||
'Pre-Chorus' => '#8B5CF6',
|
||||
'Tag' => '#EC4899',
|
||||
'Ending' => '#EF4444',
|
||||
'Intro' => '#14B8A6',
|
||||
'Interlude' => '#6366F1',
|
||||
'Outro' => '#F97316',
|
||||
'Misc' => '#64748B',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
|
|
@ -37,7 +48,7 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
$song = Song::withTrashed()->where('ccli_id', $parsed->ccliId)->first();
|
||||
$status = 'created';
|
||||
|
||||
if ($song !== null && ! $song->trashed()) {
|
||||
if ($song !== null && ! $song->trashed() && $this->songHasContent($song)) {
|
||||
throw new DuplicateCcliSongException($song->id);
|
||||
}
|
||||
|
||||
|
|
@ -58,22 +69,26 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
$warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.';
|
||||
}
|
||||
|
||||
$labelIds = [];
|
||||
$sectionIds = [];
|
||||
$hasTranslation = false;
|
||||
|
||||
foreach ($parsed->sections as $section) {
|
||||
$label = $this->resolveLabel($section);
|
||||
$labelIds[] = $label->id;
|
||||
foreach ($parsed->sections as $order => $parsedSection) {
|
||||
$label = $this->resolveLabel($parsedSection);
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $label->id],
|
||||
['order' => $order + 1],
|
||||
);
|
||||
$section->update(['order' => $order + 1]);
|
||||
$sectionIds[] = $section->id;
|
||||
|
||||
$label->songSlides()->delete();
|
||||
$section->slides()->delete();
|
||||
|
||||
foreach ($section->lines as $order => $line) {
|
||||
$translatedLine = $section->linesTranslated[$order] ?? null;
|
||||
foreach ($parsedSection->lines as $slideOrder => $line) {
|
||||
$translatedLine = $parsedSection->linesTranslated[$slideOrder] ?? null;
|
||||
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
|
||||
|
||||
SongSlide::create([
|
||||
'label_id' => $label->id,
|
||||
'order' => $order + 1,
|
||||
$section->slides()->create([
|
||||
'order' => $slideOrder + 1,
|
||||
'text_content' => $line,
|
||||
'text_content_translated' => $translatedLine,
|
||||
]);
|
||||
|
|
@ -93,15 +108,15 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
|
||||
SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete();
|
||||
|
||||
foreach ($labelIds as $order => $labelId) {
|
||||
foreach ($sectionIds as $order => $sectionId) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $labelId,
|
||||
'song_section_id' => $sectionId,
|
||||
'order' => $order + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$song = $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
|
||||
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'import',
|
||||
|
|
@ -137,15 +152,32 @@ private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $s
|
|||
return Song::create(array_merge($songData, ['ccli_id' => $parsed->ccliId]));
|
||||
}
|
||||
|
||||
private function songHasContent(Song $song): bool
|
||||
{
|
||||
return $song->sections()->whereHas('slides')->exists();
|
||||
}
|
||||
|
||||
private function resolveLabel(ParsedCcliSection $section): Label
|
||||
{
|
||||
$canonicalKind = CcliLabels::normalizeLabelName($section->kind);
|
||||
$canonicalLabelName = CcliLabels::normalizeLabelName(
|
||||
$section->kind.($section->number ? ' '.$section->number : ''),
|
||||
);
|
||||
|
||||
return Label::firstOrCreate(
|
||||
['name' => $canonicalLabelName],
|
||||
['color' => self::DEFAULT_LABEL_COLOR, 'last_imported_at' => now()],
|
||||
['color' => $this->labelColor($canonicalKind), 'last_imported_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
private function labelColor(string $canonicalKind): string
|
||||
{
|
||||
if (array_key_exists($canonicalKind, self::LABEL_KIND_COLORS)) {
|
||||
return self::LABEL_KIND_COLORS[$canonicalKind];
|
||||
}
|
||||
|
||||
$colors = array_values(self::LABEL_KIND_COLORS);
|
||||
|
||||
return $colors[crc32($canonicalKind) % count($colors)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,13 +53,30 @@ public function parse(string $rawText): ParsedCcliSong
|
|||
$copyrightText = null;
|
||||
$sections = [];
|
||||
$current = null;
|
||||
$previousLineWasBlank = false;
|
||||
$currentParagraphLineCount = 0;
|
||||
$currentParagraphStartedAfterBlank = false;
|
||||
|
||||
foreach (array_slice($lines, $firstSectionIndex) as $line) {
|
||||
if ($line === '') {
|
||||
$previousLineWasBlank = true;
|
||||
$currentParagraphLineCount = 0;
|
||||
$currentParagraphStartedAfterBlank = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isMetadataLine($line)) {
|
||||
if ($author === null
|
||||
&& $current !== null
|
||||
&& $currentParagraphLineCount === 1
|
||||
&& $currentParagraphStartedAfterBlank
|
||||
) {
|
||||
$author = array_pop($current['lines']);
|
||||
$currentParagraphLineCount = 0;
|
||||
$currentParagraphStartedAfterBlank = false;
|
||||
}
|
||||
|
||||
$extractedCcliId = CcliLabels::extractCcliId($line);
|
||||
if ($extractedCcliId !== null) {
|
||||
$ccliId = $extractedCcliId;
|
||||
|
|
@ -94,12 +111,21 @@ public function parse(string $rawText): ParsedCcliSong
|
|||
'modifier' => $label['modifier'],
|
||||
'lines' => [],
|
||||
];
|
||||
$previousLineWasBlank = false;
|
||||
$currentParagraphLineCount = 0;
|
||||
$currentParagraphStartedAfterBlank = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current !== null) {
|
||||
if ($currentParagraphLineCount === 0) {
|
||||
$currentParagraphStartedAfterBlank = $previousLineWasBlank;
|
||||
}
|
||||
|
||||
$current['lines'][] = $line;
|
||||
$currentParagraphLineCount++;
|
||||
$previousLineWasBlank = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa
|
|||
{
|
||||
$parsed = $this->parser->parse($ccliRawText);
|
||||
|
||||
$localSong->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
|
||||
$localSong->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
|
||||
$arrangement = $this->findArrangement($localSong, $arrangementName);
|
||||
|
||||
|
|
@ -47,16 +47,17 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa
|
|||
$unmatchedLabels = [];
|
||||
$allDistributedLines = [];
|
||||
|
||||
foreach ($arrangement->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
||||
if ($label === null) {
|
||||
if ($section === null || $label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localCanonical = $this->canonicalLabel($label->name, null);
|
||||
$matchedSection = $ccliByCanonical[$localCanonical] ?? null;
|
||||
$slides = $label->songSlides->sortBy('order')->values();
|
||||
$slides = $section->slides->sortBy('order')->values();
|
||||
|
||||
if ($matchedSection === null) {
|
||||
$unmatchedLabels[] = $label->name;
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ public function generatePlaylist(Service $service): array
|
|||
->orderBy('sort_order')
|
||||
->with([
|
||||
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
|
||||
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'serviceSong.arrangement.arrangementLabels.label',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSong.arrangement.arrangementSections.section.label',
|
||||
])
|
||||
->get();
|
||||
|
||||
|
|
@ -111,6 +112,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
rename($proPath, $destPath);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
|
|
@ -201,12 +203,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
*/
|
||||
private function generatePlaylistLegacy(Service $service): array
|
||||
{
|
||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
|
||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
|
||||
|
||||
$matchedSongs = $service->serviceSongs()
|
||||
->whereNotNull('song_id')
|
||||
->orderBy('order')
|
||||
->with('song.arrangements.arrangementLabels.label.songSlides')
|
||||
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
|
||||
->get();
|
||||
|
||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||
|
|
@ -243,6 +245,7 @@ private function generatePlaylistLegacy(Service $service): array
|
|||
rename($proPath, $destPath);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
|
|
@ -302,6 +305,7 @@ private function addSlidesFromCollection(
|
|||
$slideDataList = [];
|
||||
$imageFiles = [];
|
||||
$background = $this->backgroundData($service);
|
||||
$backgroundAttached = false;
|
||||
|
||||
foreach ($slides->values() as $index => $slide) {
|
||||
$storedPath = Storage::disk('public')->path($slide->stored_filename);
|
||||
|
|
@ -324,11 +328,16 @@ private function addSlidesFromCollection(
|
|||
|
||||
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
||||
$singleSlideData['background'] = $background;
|
||||
$backgroundAttached = true;
|
||||
}
|
||||
|
||||
$slideDataList[] = $singleSlideData;
|
||||
}
|
||||
|
||||
if ($backgroundAttached) {
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
}
|
||||
|
||||
if (empty($slideDataList)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -425,6 +434,8 @@ private function addKeyVisualFallbackPresentation(
|
|||
return;
|
||||
}
|
||||
|
||||
$this->embedKeyVisual($service, $embeddedFiles);
|
||||
|
||||
$label = $item->title ?: 'Keyvisual';
|
||||
$groups = [
|
||||
[
|
||||
|
|
@ -492,6 +503,8 @@ private function addKeyVisualSlide(Service $service, string $tempDir, array &$pl
|
|||
return;
|
||||
}
|
||||
|
||||
$this->embedKeyVisual($service, $embeddedFiles);
|
||||
|
||||
$slideData = ['imageOnly' => true, 'background' => $kvData];
|
||||
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
|
||||
}
|
||||
|
|
@ -531,36 +544,92 @@ private function backgroundData(?Service $service): ?array
|
|||
return null;
|
||||
}
|
||||
|
||||
$background = app(ServiceImageResolver::class)->backgroundFor($service);
|
||||
|
||||
if ($background === null) {
|
||||
if ($this->backgroundSourcePath($service) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => Storage::disk('public')->path($background),
|
||||
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'bundleRelative' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function keyVisualData(Service $service): ?array
|
||||
{
|
||||
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
|
||||
|
||||
if ($keyVisual === null) {
|
||||
if ($this->keyVisualSourcePath($service) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => Storage::disk('public')->path($keyVisual),
|
||||
'path' => ServiceImageResolver::KEY_VISUAL_EXPORT_NAME,
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'bundleRelative' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/** Absolute filesystem path of the resolved background image, or null. */
|
||||
private function backgroundSourcePath(?Service $service): ?string
|
||||
{
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$background = app(ServiceImageResolver::class)->backgroundFor($service);
|
||||
|
||||
if ($background === null || ! Storage::disk('public')->exists($background)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->path($background);
|
||||
}
|
||||
|
||||
/** Absolute filesystem path of the resolved key-visual image, or null. */
|
||||
private function keyVisualSourcePath(Service $service): ?string
|
||||
{
|
||||
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
|
||||
|
||||
if ($keyVisual === null || ! Storage::disk('public')->exists($keyVisual)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->path($keyVisual);
|
||||
}
|
||||
|
||||
/** Embed the resolved background image bytes into the archive under the fixed export name. */
|
||||
private function embedBackground(?Service $service, array &$embeddedFiles): void
|
||||
{
|
||||
$sourcePath = $this->backgroundSourcePath($service);
|
||||
|
||||
if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($sourcePath);
|
||||
if ($contents !== false) {
|
||||
$embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
|
||||
}
|
||||
}
|
||||
|
||||
/** Embed the resolved key-visual image bytes into the archive under the fixed export name. */
|
||||
private function embedKeyVisual(Service $service, array &$embeddedFiles): void
|
||||
{
|
||||
$sourcePath = $this->keyVisualSourcePath($service);
|
||||
|
||||
if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($sourcePath);
|
||||
if ($contents !== false) {
|
||||
$embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME] = $contents;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
||||
{
|
||||
if ($background === null) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
$agendaItem->loadMissing([
|
||||
'service',
|
||||
'slides',
|
||||
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
]);
|
||||
|
||||
$title = $agendaItem->title ?: 'Ablauf-Element';
|
||||
|
|
@ -63,7 +64,10 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
||||
$proFilename = self::safeFilename($song->title).'.pro';
|
||||
|
||||
$bundle = new PresentationBundle($parserSong, $proFilename);
|
||||
$songMediaFiles = [];
|
||||
$this->embedBackground($agendaItem->service, $songMediaFiles);
|
||||
|
||||
$bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles);
|
||||
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
|
||||
ProBundleWriter::write($bundle, $bundlePath);
|
||||
|
||||
|
|
@ -95,6 +99,7 @@ private function buildBundleFromSlides(
|
|||
$slideData = [];
|
||||
$mediaFiles = [];
|
||||
$background = $this->backgroundData($service);
|
||||
$backgroundAttached = false;
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
||||
|
|
@ -118,6 +123,7 @@ private function buildBundleFromSlides(
|
|||
|
||||
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
||||
$singleSlideData['background'] = $background;
|
||||
$backgroundAttached = true;
|
||||
}
|
||||
|
||||
if ($service !== null && $partType !== null) {
|
||||
|
|
@ -138,6 +144,10 @@ private function buildBundleFromSlides(
|
|||
$slideData[] = $singleSlideData;
|
||||
}
|
||||
|
||||
if ($backgroundAttached) {
|
||||
$this->embedBackground($service, $mediaFiles);
|
||||
}
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'name' => $groupName,
|
||||
|
|
@ -164,6 +174,22 @@ private function buildBundleFromSlides(
|
|||
}
|
||||
|
||||
private function backgroundData(?Service $service): ?array
|
||||
{
|
||||
if ($this->backgroundSourcePath($service) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'bundleRelative' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/** Absolute filesystem path of the resolved background image, or null. */
|
||||
private function backgroundSourcePath(?Service $service): ?string
|
||||
{
|
||||
if ($service === null) {
|
||||
return null;
|
||||
|
|
@ -171,16 +197,26 @@ private function backgroundData(?Service $service): ?array
|
|||
|
||||
$background = $this->imageResolver->backgroundFor($service);
|
||||
|
||||
if ($background === null) {
|
||||
if ($background === null || ! Storage::disk('public')->exists($background)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => Storage::disk('public')->path($background),
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
];
|
||||
return Storage::disk('public')->path($background);
|
||||
}
|
||||
|
||||
/** Embed the resolved background image bytes into the bundle under the fixed export name. */
|
||||
private function embedBackground(?Service $service, array &$mediaFiles): void
|
||||
{
|
||||
$sourcePath = $this->backgroundSourcePath($service);
|
||||
|
||||
if ($sourcePath === null || isset($mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($sourcePath);
|
||||
if ($contents !== false) {
|
||||
$mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use App\Models\Service;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
|
||||
class ProExportService
|
||||
|
|
@ -31,7 +30,7 @@ public function generateProFile(Song $song, ?Service $service = null): string
|
|||
|
||||
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
|
||||
{
|
||||
$song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
|
||||
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
|
||||
return ProFileGenerator::generate(
|
||||
$song->title,
|
||||
|
|
@ -49,29 +48,30 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
return [];
|
||||
}
|
||||
|
||||
$defaultArr->loadMissing('arrangementLabels.label.songSlides');
|
||||
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
|
||||
|
||||
$groups = [];
|
||||
$seenLabelIds = [];
|
||||
$seenSectionIds = [];
|
||||
$background = $this->backgroundData($service);
|
||||
|
||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
||||
if ($label === null) {
|
||||
if ($section === null || $label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($label->id, $seenLabelIds, true)) {
|
||||
if (in_array($section->id, $seenSectionIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$seenLabelIds[] = $label->id;
|
||||
$seenSectionIds[] = $section->id;
|
||||
|
||||
$slides = [];
|
||||
$labelSlides = $label->songSlides->sortBy('order')->values();
|
||||
$totalSlides = $labelSlides->count();
|
||||
$sectionSlides = $section->slides->sortBy('order')->values();
|
||||
$totalSlides = $sectionSlides->count();
|
||||
|
||||
foreach ($labelSlides as $slideIndex => $slide) {
|
||||
foreach ($sectionSlides as $slideIndex => $slide) {
|
||||
$slideData = ['text' => $slide->text_content ?? ''];
|
||||
|
||||
if ($slide->text_content_translated) {
|
||||
|
|
@ -121,10 +121,11 @@ private function backgroundData(?Service $service): ?array
|
|||
}
|
||||
|
||||
return [
|
||||
'path' => Storage::disk('public')->path($background),
|
||||
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'bundleRelative' => true,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -142,11 +143,11 @@ private function buildArrangements(Song $song): array
|
|||
$arrangements = [];
|
||||
|
||||
foreach ($song->arrangements as $arrangement) {
|
||||
$arrangement->loadMissing('arrangementLabels.label');
|
||||
$arrangement->loadMissing('arrangementSections.section.label');
|
||||
|
||||
$groupNames = $arrangement->arrangementLabels
|
||||
$groupNames = $arrangement->arrangementSections
|
||||
->sortBy('order')
|
||||
->map(fn ($al) => $al->label?->name)
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Support\MacroColorConverter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -104,14 +105,14 @@ private function upsertSong(ProSong $proSong): Song
|
|||
}
|
||||
|
||||
$song->arrangements()->each(function (SongArrangement $arr) {
|
||||
$arr->arrangementLabels()->delete();
|
||||
$arr->arrangementSections()->delete();
|
||||
});
|
||||
$song->arrangements()->delete();
|
||||
|
||||
$hasTranslation = false;
|
||||
$labelsByName = [];
|
||||
$sectionsByName = [];
|
||||
|
||||
foreach ($proSong->getGroups() as $proGroup) {
|
||||
foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
|
||||
$groupName = $proGroup->getName();
|
||||
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
|
||||
|
||||
|
|
@ -125,9 +126,14 @@ private function upsertSong(ProSong $proSong): Song
|
|||
]);
|
||||
}
|
||||
|
||||
$labelsByName[$groupName] = $existingLabel;
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $existingLabel->id],
|
||||
['order' => $groupOrder + 1],
|
||||
);
|
||||
$section->update(['order' => $groupOrder + 1]);
|
||||
$sectionsByName[$groupName] = $section;
|
||||
|
||||
$existingLabel->songSlides()->delete();
|
||||
$section->slides()->delete();
|
||||
|
||||
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
|
||||
$translatedText = null;
|
||||
|
|
@ -137,7 +143,7 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$hasTranslation = true;
|
||||
}
|
||||
|
||||
$existingLabel->songSlides()->create([
|
||||
$section->slides()->create([
|
||||
'order' => $slidePosition,
|
||||
'text_content' => $proSlide->getPlainText(),
|
||||
'text_content_translated' => $translatedText,
|
||||
|
|
@ -156,19 +162,19 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
|
||||
|
||||
foreach ($groupsInArrangement as $order => $proGroup) {
|
||||
$label = $labelsByName[$proGroup->getName()] ?? null;
|
||||
$section = $sectionsByName[$proGroup->getName()] ?? null;
|
||||
|
||||
if ($label) {
|
||||
if ($section) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
|
||||
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
}
|
||||
|
||||
public static function rgbaToHex(array $rgba): string
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
|
||||
class ServiceImageResolver
|
||||
{
|
||||
/**
|
||||
* Fixed export filename for the key-visual image. The image bytes are
|
||||
* embedded under this name AND referenced by this name inside the .pro file.
|
||||
*/
|
||||
public const KEY_VISUAL_EXPORT_NAME = 'KEY_VISUAL.jpg';
|
||||
|
||||
/**
|
||||
* Fixed export filename for the background image. The image bytes are
|
||||
* embedded under this name AND referenced by this name inside the .pro file.
|
||||
*/
|
||||
public const BACKGROUND_EXPORT_NAME = 'BACKGROUND.jpg';
|
||||
|
||||
public function keyVisualFor(Service $service): ?string
|
||||
{
|
||||
return $this->resolve($service->key_visual_filename, 'current_key_visual');
|
||||
|
|
|
|||
|
|
@ -6,15 +6,16 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongService
|
||||
{
|
||||
/**
|
||||
* Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren.
|
||||
* Sicherstellen, dass die Default-Labels und Song-Sektionen existieren.
|
||||
*
|
||||
* @return Collection<int, Label>
|
||||
* @return Collection<int, SongSection>
|
||||
*/
|
||||
public function createDefaultGroups(Song $song): Collection
|
||||
{
|
||||
|
|
@ -24,9 +25,9 @@ public function createDefaultGroups(Song $song): Collection
|
|||
['name' => 'Bridge', 'color' => '#F59E0B'],
|
||||
];
|
||||
|
||||
$labels = collect();
|
||||
$sections = collect();
|
||||
|
||||
foreach ($defaults as $data) {
|
||||
foreach ($defaults as $index => $data) {
|
||||
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
|
||||
|
||||
if ($existing === null) {
|
||||
|
|
@ -36,10 +37,16 @@ public function createDefaultGroups(Song $song): Collection
|
|||
]);
|
||||
}
|
||||
|
||||
$labels->push($existing);
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $existing->id],
|
||||
['order' => $index + 1],
|
||||
);
|
||||
$section->update(['order' => $index + 1]);
|
||||
|
||||
$sections->push($section);
|
||||
}
|
||||
|
||||
return $labels;
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,16 +59,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
|
|||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$labels = $this->createDefaultGroups($song);
|
||||
$sections = $this->createDefaultGroups($song);
|
||||
|
||||
foreach ($labels->values() as $index => $label) {
|
||||
$arrangement->arrangementLabels()->create([
|
||||
'label_id' => $label->id,
|
||||
foreach ($sections->values() as $index => $section) {
|
||||
$arrangement->arrangementSections()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $arrangement->load('arrangementLabels.label');
|
||||
return $arrangement->load('arrangementSections.section.label');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,15 +82,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
|
|||
$clone->is_default = false;
|
||||
$clone->save();
|
||||
|
||||
foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) {
|
||||
foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $clone->id,
|
||||
'label_id' => $arrangementLabel->label_id,
|
||||
'order' => $arrangementLabel->order,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $arrangementSection->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return $clone->load('arrangementLabels.label');
|
||||
return $clone->load('arrangementSections.section.label');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function importTranslation(Song $song, string $text): void
|
|||
|
||||
$defaultArr = $song->arrangements()
|
||||
->where('is_default', true)
|
||||
->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides'])
|
||||
->with(['arrangementSections' => fn ($q) => $q->orderBy('order'), 'arrangementSections.section.slides'])
|
||||
->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
|
|
@ -43,14 +43,14 @@ public function importTranslation(Song $song, string $text): void
|
|||
return;
|
||||
}
|
||||
|
||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
|
||||
if ($label === null) {
|
||||
if ($section === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($label->songSlides->sortBy('order') as $slide) {
|
||||
foreach ($section->slides->sortBy('order') as $slide) {
|
||||
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
|
||||
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
||||
$offset += $originalLineCount;
|
||||
|
|
@ -71,15 +71,13 @@ public function markAsTranslated(Song $song): void
|
|||
|
||||
public function removeTranslation(Song $song): void
|
||||
{
|
||||
$labelIds = $song->arrangements()
|
||||
->with('arrangementLabels')
|
||||
->get()
|
||||
->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
|
||||
$sectionIds = $song->sections()
|
||||
->pluck('id')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($labelIds->isNotEmpty()) {
|
||||
SongSlide::whereIn('label_id', $labelIds)->update([
|
||||
if ($sectionIds->isNotEmpty()) {
|
||||
SongSlide::whereIn('song_section_id', $sectionIds)->update([
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ final class CcliLabels
|
|||
/**
|
||||
* Regex matching CCLI SongSelect section labels (English + German + variants).
|
||||
*/
|
||||
public const SECTION_LABEL_PATTERN = '/^(Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu';
|
||||
public const SECTION_LABEL_PATTERN = '/^(Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu';
|
||||
|
||||
/**
|
||||
* Regex matching CCLI footer/metadata lines (copyright, CCLI number).
|
||||
|
|
@ -18,6 +18,7 @@ final class CcliLabels
|
|||
* Bidirectional English ↔ German label kind mapping.
|
||||
*/
|
||||
public const LABEL_NAME_MAP = [
|
||||
'Vers' => 'Verse',
|
||||
'Strophe' => 'Verse',
|
||||
'Refrain' => 'Chorus',
|
||||
'Brücke' => 'Bridge',
|
||||
|
|
@ -53,7 +54,7 @@ public static function normalizeLabelName(string $label): string
|
|||
{
|
||||
$trimmed = trim($label);
|
||||
|
||||
if (! preg_match('/^(?<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;
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ public static function parseLabel(string $line): ?array
|
|||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
|
|
|
|||
4
database/factories/SongArrangementLabelFactory.php
Normal file → Executable file
4
database/factories/SongArrangementLabelFactory.php
Normal file → Executable file
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class SongArrangementLabelFactory extends Factory
|
||||
|
|
@ -15,7 +15,7 @@ public function definition(): array
|
|||
{
|
||||
return [
|
||||
'song_arrangement_id' => SongArrangement::factory(),
|
||||
'label_id' => Label::factory(),
|
||||
'song_section_id' => SongSection::factory(),
|
||||
'order' => $this->faker->numberBetween(0, 10),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
22
database/factories/SongArrangementSectionFactory.php
Normal file
22
database/factories/SongArrangementSectionFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
database/factories/SongSectionFactory.php
Normal file
22
database/factories/SongSectionFactory.php
Normal 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
4
database/factories/SongSlideFactory.php
Normal file → Executable file
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\SongSlide;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ class SongSlideFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'label_id' => Label::factory(),
|
||||
'song_section_id' => SongSection::factory(),
|
||||
'order' => $this->faker->numberBetween(1, 12),
|
||||
'text_content' => implode("\n", $this->faker->sentences(3)),
|
||||
'text_content_translated' => $this->faker->optional()->sentence(),
|
||||
|
|
|
|||
|
|
@ -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
520
package-lock.json
generated
|
|
@ -28,9 +28,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -38,9 +38,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -48,13 +48,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
|
|
@ -64,14 +64,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -520,9 +520,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@inertiajs/core": {
|
||||
"version": "2.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.23.tgz",
|
||||
"integrity": "sha512-d+jcdf91RY5bjT+ivBn2gu9Qsmxx5CacvVmVp7o9H2Lu/k1cBD0m4UZB5YKWfG11aBCucShBxuppEXme7sSEow==",
|
||||
"version": "2.3.24",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.24.tgz",
|
||||
"integrity": "sha512-xAlUl5+RKtdbutEgsmdWa6HmnvjIGcWTrvfLj/3Icy3/7bSH3aiI+kuYPs17LBq/SMaXnqBZXXo094rEXUv2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -534,13 +534,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@inertiajs/vue3": {
|
||||
"version": "2.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.23.tgz",
|
||||
"integrity": "sha512-W6rhjNCIKQQYZsNFdlFEa+prATbXcIR5k9VMg3PsYfRmqTYTORZspdrTH7JLPMneLuhNiI/Y6VbFR0TxGdv5Fg==",
|
||||
"version": "2.3.24",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.24.tgz",
|
||||
"integrity": "sha512-TokM+JU88YTHClh/LcKk31qiIAZFq3RQ4BBf1dxvk6MV45KWYemJMpLS6WFJ5NaSv6rZFlZrRc92N0ZdyOC/HA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.23",
|
||||
"@inertiajs/core": "2.3.24",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"laravel-precognition": "^1.0.2",
|
||||
"lodash-es": "^4.18.1"
|
||||
|
|
@ -617,13 +617,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -633,16 +633,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
|
||||
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -654,9 +654,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -668,9 +668,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -682,9 +682,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -696,9 +696,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -710,9 +710,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -724,9 +724,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
|
||||
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
|
||||
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -738,9 +738,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
|
||||
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
|
||||
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -752,9 +752,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -766,9 +766,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -780,9 +780,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -794,9 +794,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -808,9 +808,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -822,9 +822,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -836,9 +836,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -850,9 +850,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -864,9 +864,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -878,9 +878,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -892,9 +892,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -906,9 +906,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -920,9 +920,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -934,9 +934,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -948,9 +948,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -962,9 +962,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -976,9 +976,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1313,13 +1313,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
|
||||
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||
"integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
|
@ -1330,111 +1330,111 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
||||
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
|
||||
"integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/shared": "3.5.34",
|
||||
"@vue/shared": "3.5.35",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
|
||||
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
|
||||
"integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
"@vue/compiler-core": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
|
||||
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
|
||||
"integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/compiler-ssr": "3.5.34",
|
||||
"@vue/shared": "3.5.34",
|
||||
"@vue/compiler-core": "3.5.35",
|
||||
"@vue/compiler-dom": "3.5.35",
|
||||
"@vue/compiler-ssr": "3.5.35",
|
||||
"@vue/shared": "3.5.35",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss": "^8.5.15",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
|
||||
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
|
||||
"integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
"@vue/compiler-dom": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
|
||||
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
|
||||
"integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.34"
|
||||
"@vue/shared": "3.5.35"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
|
||||
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
|
||||
"integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
"@vue/reactivity": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
|
||||
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
|
||||
"integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.34",
|
||||
"@vue/runtime-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34",
|
||||
"@vue/reactivity": "3.5.35",
|
||||
"@vue/runtime-core": "3.5.35",
|
||||
"@vue/shared": "3.5.35",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
|
||||
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
|
||||
"integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
"@vue/compiler-ssr": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.34"
|
||||
"vue": "3.5.35"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
||||
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
|
||||
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -1477,6 +1477,19 @@
|
|||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
|
|
@ -1548,21 +1561,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
||||
"version": "2.10.33",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -1638,9 +1652,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1768,6 +1782,24 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
@ -1804,9 +1836,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.353",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
|
||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||
"version": "1.5.364",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
||||
"integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -1818,9 +1850,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz",
|
||||
"integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==",
|
||||
"version": "5.22.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
|
||||
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1865,9 +1897,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2156,9 +2188,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2168,6 +2200,20 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
|
|
@ -2540,6 +2586,13 @@
|
|||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
|
|
@ -2560,11 +2613,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
|
|
@ -2600,13 +2656,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -2619,9 +2675,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -2632,9 +2688,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2652,7 +2708,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -2678,9 +2734,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
|
@ -2716,9 +2772,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2732,31 +2788,31 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.3",
|
||||
"@rollup/rollup-android-arm64": "4.60.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.3",
|
||||
"@rollup/rollup-darwin-x64": "4.60.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.3",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||
"@rollup/rollup-android-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -2935,9 +2991,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3114,17 +3170,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
||||
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
|
||||
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/compiler-sfc": "3.5.34",
|
||||
"@vue/runtime-dom": "3.5.34",
|
||||
"@vue/server-renderer": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
"@vue/compiler-dom": "3.5.35",
|
||||
"@vue/compiler-sfc": "3.5.35",
|
||||
"@vue/runtime-dom": "3.5.35",
|
||||
"@vue/server-renderer": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ function saveArrangement() {
|
|||
`/arrangements/${selectedArrangement.value.id}`,
|
||||
{
|
||||
groups: arrangementGroups.value.map((group, index) => ({
|
||||
label_id: group.id,
|
||||
section_id: group.section_id ?? group.id,
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
|
|||
import { router } from '@inertiajs/vue3'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
|
||||
import SongEditModal from '@/Components/SongEditModal.vue'
|
||||
|
||||
const MASTER_ID = 'master'
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ const selectedSongId = ref('')
|
|||
const dropdownOpen = ref(false)
|
||||
const assignError = ref('')
|
||||
const ccliDialogOpen = ref(false)
|
||||
const editSongId = ref(null)
|
||||
|
||||
function normalize(value) {
|
||||
return (value ?? '').toString().toLowerCase().trim()
|
||||
|
|
@ -552,7 +554,7 @@ function closeOnBackdrop(e) {
|
|||
</button>
|
||||
<a
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="songselect-search-button"
|
||||
|
|
@ -765,6 +767,15 @@ function closeOnBackdrop(e) {
|
|||
:service-song-id="props.serviceSongId"
|
||||
@close="ccliDialogOpen = false"
|
||||
@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>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const props = defineProps({
|
|||
prefilledText: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'imported', 'paired'])
|
||||
const emit = defineEmits(['close', 'imported', 'paired', 'edit-song'])
|
||||
|
||||
const pasteText = ref('')
|
||||
const preview = ref(null)
|
||||
|
|
@ -94,7 +94,8 @@ async function doImport(importMode) {
|
|||
}
|
||||
|
||||
if (importMode === 'edit') {
|
||||
router.visit('/songs/' + data.song_id)
|
||||
emit('edit-song', data.song_id)
|
||||
emit('close')
|
||||
} else if (importMode === 'pair') {
|
||||
router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true')
|
||||
} else {
|
||||
|
|
@ -110,7 +111,8 @@ async function doImport(importMode) {
|
|||
</script>
|
||||
|
||||
<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">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
|
@ -131,8 +133,8 @@ async function doImport(importMode) {
|
|||
<!-- Instructions -->
|
||||
<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>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>Füge den Text unten ein und klicke <strong>Vorschau</strong></li>
|
||||
<li>Klicke das <strong>Kopier-Symbol</strong> neben dem Liedtext – es kopiert Titel, Liedtext und CCLI-Infos</li>
|
||||
<li>Füge alles unten ein und klicke auf <strong>„Vorschau"</strong></li>
|
||||
</ol>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{{ error }}
|
||||
<a
|
||||
<button
|
||||
v-if="existingSongId"
|
||||
:href="'/songs/' + existingSongId"
|
||||
type="button"
|
||||
@click="emit('edit-song', existingSongId); emit('close')"
|
||||
data-testid="ccli-existing-song-link"
|
||||
class="ml-2 underline font-medium"
|
||||
>Vorhandenen Song bearbeiten</a>
|
||||
>Vorhandenen Song bearbeiten</button>
|
||||
</div>
|
||||
|
||||
<!-- 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">Jahr:</span> {{ preview.year || '–' }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Sektionen: {{ preview.sections?.map(s => s.label).join(', ') }}
|
||||
<div class="max-h-72 overflow-auto space-y-3">
|
||||
<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>
|
||||
|
||||
|
|
@ -245,4 +261,5 @@ async function doImport(importMode) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -21,17 +21,77 @@ const emit = defineEmits(['close', 'updated'])
|
|||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const songData = ref(null)
|
||||
const sectionDrafts = ref({})
|
||||
|
||||
const title = ref('')
|
||||
const ccliId = ref('')
|
||||
const copyrightText = ref('')
|
||||
const showAddSectionForm = ref(false)
|
||||
const newSectionLabel = ref('')
|
||||
const newSectionText = ref('')
|
||||
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
let savedTimeout = null
|
||||
const sectionSaveDebouncers = new Map()
|
||||
|
||||
/* ── Save status ── */
|
||||
|
||||
function startSaving() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
}
|
||||
|
||||
function finishSaving() {
|
||||
saving.value = false
|
||||
saved.value = true
|
||||
|
||||
if (savedTimeout) clearTimeout(savedTimeout)
|
||||
savedTimeout = setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopSaving() {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
/* ── Data fetching ── */
|
||||
|
||||
function slidesToText(slides = [], key) {
|
||||
return slides
|
||||
.map((slide) => slide[key] ?? '')
|
||||
.filter((text) => text !== '')
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function sectionKey(group) {
|
||||
return group.section_id ?? group.id
|
||||
}
|
||||
|
||||
function setSectionDrafts(data) {
|
||||
const drafts = {}
|
||||
|
||||
;(data?.groups ?? []).forEach((group) => {
|
||||
drafts[sectionKey(group)] = {
|
||||
text: slidesToText(group.slides, 'text_content'),
|
||||
translated: slidesToText(group.slides, 'text_content_translated'),
|
||||
}
|
||||
})
|
||||
|
||||
sectionDrafts.value = drafts
|
||||
}
|
||||
|
||||
function draftFor(group) {
|
||||
const key = sectionKey(group)
|
||||
|
||||
if (!sectionDrafts.value[key]) {
|
||||
sectionDrafts.value[key] = { text: '', translated: '' }
|
||||
}
|
||||
|
||||
return sectionDrafts.value[key]
|
||||
}
|
||||
|
||||
const fetchSong = async () => {
|
||||
if (!props.songId) return
|
||||
|
||||
|
|
@ -53,6 +113,7 @@ const fetchSong = async () => {
|
|||
|
||||
const json = await response.json()
|
||||
songData.value = json.data
|
||||
setSectionDrafts(json.data)
|
||||
|
||||
title.value = json.data.title ?? ''
|
||||
ccliId.value = json.data.ccli_id ?? ''
|
||||
|
|
@ -73,7 +134,11 @@ watch(
|
|||
|
||||
if (!isVisible) {
|
||||
songData.value = null
|
||||
sectionDrafts.value = {}
|
||||
error.value = null
|
||||
showAddSectionForm.value = false
|
||||
newSectionLabel.value = ''
|
||||
newSectionText.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -83,8 +148,7 @@ watch(
|
|||
const performSave = async (data) => {
|
||||
if (!props.songId) return
|
||||
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
|
|
@ -105,17 +169,11 @@ const performSave = async (data) => {
|
|||
throw new Error('Speichern fehlgeschlagen')
|
||||
}
|
||||
|
||||
saving.value = false
|
||||
saved.value = true
|
||||
|
||||
if (savedTimeout) clearTimeout(savedTimeout)
|
||||
savedTimeout = setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
finishSaving()
|
||||
|
||||
emit('updated')
|
||||
} catch {
|
||||
saving.value = false
|
||||
stopSaving()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,10 +208,12 @@ const arrangements = computed(() => {
|
|||
name: arr.name,
|
||||
is_default: arr.is_default,
|
||||
groups: arr.arrangement_groups.map((ag) => {
|
||||
const group = songData.value.groups.find((g) => g.id === ag.label_id)
|
||||
const group = songData.value.groups.find((g) => g.id === (ag.section_id ?? ag.label_id))
|
||||
|
||||
return {
|
||||
id: ag.label_id,
|
||||
id: ag.section_id ?? ag.label_id,
|
||||
section_id: ag.section_id ?? ag.label_id,
|
||||
label_id: ag.label_id,
|
||||
name: group?.name ?? 'Unbekannt',
|
||||
color: group?.color ?? '#6b7280',
|
||||
order: ag.order,
|
||||
|
|
@ -167,11 +227,161 @@ const availableGroups = computed(() => {
|
|||
|
||||
return songData.value.groups.map((group) => ({
|
||||
id: group.id,
|
||||
section_id: group.section_id ?? group.id,
|
||||
label_id: group.label_id,
|
||||
name: group.name,
|
||||
color: group.color,
|
||||
}))
|
||||
})
|
||||
|
||||
const sectionLabelOptions = computed(() => {
|
||||
if (!songData.value?.groups) return []
|
||||
|
||||
return [...new Set(songData.value.groups.map((group) => group.name).filter(Boolean))]
|
||||
})
|
||||
|
||||
/* ── Section editing ── */
|
||||
|
||||
function splitSectionText(value) {
|
||||
const trimmed = (value ?? '').replace(/\r\n/g, '\n').replace(/\s+$/u, '')
|
||||
const blocks = trimmed
|
||||
.split(/\n\s*\n+/u)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block !== '')
|
||||
|
||||
return blocks.length ? blocks : ['']
|
||||
}
|
||||
|
||||
function buildSectionSlides(sectionId) {
|
||||
const draft = sectionDrafts.value[sectionId] ?? { text: '', translated: '' }
|
||||
const textBlocks = splitSectionText(draft.text)
|
||||
const translatedBlocks = (draft.translated ?? '').trim() === '' ? [] : splitSectionText(draft.translated)
|
||||
|
||||
return textBlocks.map((text, index) => ({
|
||||
text_content: text,
|
||||
text_content_translated: translatedBlocks[index] ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveSection(sectionId) {
|
||||
if (!props.songId || !sectionDrafts.value[sectionId]) return
|
||||
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
slides: buildSectionSlides(sectionId),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sektion konnte nicht gespeichert werden.')
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
songData.value = json.data
|
||||
setSectionDrafts(json.data)
|
||||
finishSaving()
|
||||
emit('updated')
|
||||
} catch {
|
||||
stopSaving()
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionInput(sectionId) {
|
||||
if (!sectionSaveDebouncers.has(sectionId)) {
|
||||
sectionSaveDebouncers.set(sectionId, useDebounceFn(() => {
|
||||
saveSection(sectionId)
|
||||
}, 600))
|
||||
}
|
||||
|
||||
sectionSaveDebouncers.get(sectionId)()
|
||||
}
|
||||
|
||||
function onSectionBlur(sectionId) {
|
||||
sectionSaveDebouncers.get(sectionId)?.cancel?.()
|
||||
saveSection(sectionId)
|
||||
}
|
||||
|
||||
async function deleteSection(sectionId) {
|
||||
if (!window.confirm('Möchtest Du diese Sektion wirklich löschen?')) return
|
||||
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sektion konnte nicht gelöscht werden.')
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
songData.value = json.data
|
||||
setSectionDrafts(json.data)
|
||||
finishSaving()
|
||||
emit('updated')
|
||||
} catch {
|
||||
stopSaving()
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection() {
|
||||
if (!props.songId || !newSectionLabel.value.trim()) return
|
||||
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const response = await fetch(route('songs.sections.store', props.songId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
label_name: newSectionLabel.value,
|
||||
slides: splitSectionText(newSectionText.value).map((text) => ({ text_content: text })),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sektion konnte nicht hinzugefügt werden.')
|
||||
}
|
||||
|
||||
newSectionLabel.value = ''
|
||||
newSectionText.value = ''
|
||||
showAddSectionForm.value = false
|
||||
|
||||
await fetchSong()
|
||||
finishSaving()
|
||||
emit('updated')
|
||||
} catch {
|
||||
stopSaving()
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Close handling ── */
|
||||
|
||||
const closeOnEscape = (e) => {
|
||||
|
|
@ -481,6 +691,155 @@ onUnmounted(() => {
|
|||
</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 -->
|
||||
<div class="px-6 py-5">
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ async function updateSetting(key, value) {
|
|||
<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>
|
||||
<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>
|
||||
</details>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
|
||||
import SongEditModal from '@/Components/SongEditModal.vue'
|
||||
import { Head, router } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
prefilledText: { type: String, default: null },
|
||||
|
|
@ -9,6 +11,8 @@ const props = defineProps({
|
|||
prefillError: { type: String, default: null },
|
||||
})
|
||||
|
||||
const editSongId = ref(null)
|
||||
|
||||
function handleClose() {
|
||||
router.visit(route('songs.index'))
|
||||
}
|
||||
|
|
@ -49,6 +53,13 @@ function handleImported(songId, mode) {
|
|||
:prefilled-text="prefilledText"
|
||||
@close="handleClose"
|
||||
@imported="handleImported"
|
||||
@edit-song="(id) => { editSongId = id }"
|
||||
/>
|
||||
|
||||
<SongEditModal
|
||||
:show="editSongId !== null"
|
||||
:song-id="editSongId"
|
||||
@close="() => router.visit(route('songs.index'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ function pageRange() {
|
|||
<div class="mb-4 flex items-center gap-2">
|
||||
<a
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="songselect-search-button-songdb"
|
||||
|
|
@ -789,5 +789,6 @@ function pageRange() {
|
|||
mode="songdb"
|
||||
@close="ccliDialogOpen = false"
|
||||
@imported="(songId, mode) => { ccliDialogOpen = false; if (mode === 'stay') { router.reload({ only: ['songs'] }) } }"
|
||||
@edit-song="(id) => { ccliDialogOpen = false; openEditModal({ id }) }"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Http\Controllers\ServiceMacroOverrideController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use App\Http\Controllers\SongPdfController;
|
||||
use App\Http\Controllers\SongSectionController;
|
||||
use App\Http\Controllers\SyncController;
|
||||
use App\Http\Controllers\TranslationController;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -90,6 +91,9 @@
|
|||
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::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}/preview', [SongPdfController::class, 'preview'])->name('songs.preview');
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
|
|||
|
||||
$song = Song::factory()->create(['title' => 'Amazing Grace']);
|
||||
$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([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -113,7 +114,7 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
|
|||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -37,13 +38,13 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
|
|||
$defaultLabelOrder = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('label_id')
|
||||
->pluck('song_section_id')
|
||||
->all();
|
||||
|
||||
$newLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $newArrangement->id)
|
||||
->orderBy('order')
|
||||
->pluck('label_id')
|
||||
->pluck('song_section_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($defaultLabelOrder, $newLabels);
|
||||
|
|
@ -72,13 +73,13 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups():
|
|||
$originalLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('label_id')
|
||||
->pluck('song_section_id')
|
||||
->all();
|
||||
|
||||
$cloneLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $clone->id)
|
||||
->orderBy('order')
|
||||
->pluck('label_id')
|
||||
->pluck('song_section_id')
|
||||
->all();
|
||||
|
||||
$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), [
|
||||
'groups' => [
|
||||
['label_id' => $chorus->id, 'order' => 1],
|
||||
['label_id' => $bridge->id, 'order' => 2],
|
||||
['label_id' => $verse->id, 'order' => 3],
|
||||
['label_id' => $chorus->id, 'order' => 4],
|
||||
['section_id' => $chorus->id, 'order' => 1],
|
||||
['section_id' => $bridge->id, 'order' => 2],
|
||||
['section_id' => $verse->id, 'order' => 3],
|
||||
['section_id' => $chorus->id, 'order' => 4],
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
@ -104,7 +105,7 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
|
|||
$updated = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('label_id')
|
||||
->pluck('song_section_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
|
|
@ -136,9 +137,13 @@ private function createSongWithDefaultArrangement(): array
|
|||
{
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$verse = Label::factory()->create(['name' => 'Verse 1']);
|
||||
$chorus = Label::factory()->create(['name' => 'Chorus']);
|
||||
$bridge = Label::factory()->create(['name' => 'Bridge']);
|
||||
$verseLabel = Label::factory()->create(['name' => 'Verse 1']);
|
||||
$chorusLabel = Label::factory()->create(['name' => 'Chorus']);
|
||||
$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([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -148,19 +153,19 @@ private function createSongWithDefaultArrangement(): array
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verse->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'label_id' => $chorus->id,
|
||||
'song_section_id' => $chorus->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verse->id,
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
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->assertStatus(200);
|
||||
$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');
|
||||
|
||||
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');
|
||||
$content = $response->getContent();
|
||||
$content = (string) $response->getContent();
|
||||
|
||||
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');
|
||||
$content = $response->getContent();
|
||||
|
||||
expect($content)->toContain('import-from-ccli-paste');
|
||||
expect($content)->toContain('songselect.ccli.com');
|
||||
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->assertStatus(200);
|
||||
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Exceptions\DuplicateCcliSongException;
|
||||
use App\Models\ApiRequestLog;
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSlide;
|
||||
|
|
@ -76,6 +77,39 @@ function ccliFixture(string $name): string
|
|||
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 () {
|
||||
$service = app(CcliImportService::class);
|
||||
$first = $service->import(ccliFixture('english-only-multi-verse.txt'));
|
||||
|
|
|
|||
|
|
@ -74,6 +74,28 @@ function ccliFixtureContent(string $filename): string
|
|||
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 {
|
||||
$parser = new CcliPasteParser;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\SongSlide;
|
||||
use App\Services\CcliTranslationPairingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -26,16 +27,21 @@ function makeLocalSongForCcliPairing(array $labelConfig): Song
|
|||
['name' => $labelName],
|
||||
['color' => '#3B82F6'],
|
||||
);
|
||||
$section = SongSection::create([
|
||||
'song_id' => $song->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => $order + 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $order + 1,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < $slideCount; $i++) {
|
||||
SongSlide::create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $i + 1,
|
||||
'text_content' => "Original line $i for $labelName",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -118,12 +118,15 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
|
|||
$this->assertNotEmpty($songSlides);
|
||||
foreach ($songSlides as $slide) {
|
||||
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media');
|
||||
$this->assertSame(
|
||||
Storage::disk('public')->path('slides/bg.jpg'),
|
||||
$slide->getBackgroundMediaUrl(),
|
||||
);
|
||||
$this->assertSame('BACKGROUND.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');
|
||||
$this->assertNotNull($sermonParser, 'Embedded sermon .pro missing');
|
||||
$sermonSlides = $this->allParserSlides($sermonParser);
|
||||
|
|
@ -259,10 +262,11 @@ private function createSongWithContent(string $title): Song
|
|||
['name' => 'Verse 1 - '.$title],
|
||||
['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->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisua
|
|||
$slides = $this->allParserSlides($fallbackSong);
|
||||
$this->assertCount(1, $slides);
|
||||
$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']);
|
||||
}
|
||||
|
|
@ -200,10 +202,11 @@ private function createSongWithContent(string $title): Song
|
|||
['name' => 'Verse 1 - '.$title],
|
||||
['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->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,25 @@
|
|||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('song_slides has label_id column after migration', function () {
|
||||
expect(Schema::hasColumn('song_slides', 'label_id'))->toBeTrue();
|
||||
test('song_slides belongs to song_sections after migration', function () {
|
||||
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();
|
||||
});
|
||||
|
||||
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 () {
|
||||
expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('song_arrangement_labels table exists with expected columns', function () {
|
||||
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 () {
|
||||
|
|
|
|||
|
|
@ -40,17 +40,19 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
|
|||
['name' => 'Verse 1 - '.$title],
|
||||
['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(
|
||||
['name' => 'Chorus - '.$title],
|
||||
['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->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,19 +54,22 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
|
|||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$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->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->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText());
|
||||
$this->assertTrue($nameTagSlides[0]->hasMacro());
|
||||
|
||||
$sermonSlides = $this->slidesForEntry($playlist, $entries[2]);
|
||||
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
|
||||
$this->assertCount(2, $sermonSlides);
|
||||
$this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel());
|
||||
$this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel());
|
||||
|
|
@ -226,10 +229,11 @@ private function createSongWithContent(string $title): Song
|
|||
['name' => 'Verse 1 - '.$title],
|
||||
['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->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,19 +39,21 @@ private function createSongWithContent(): Song
|
|||
['name' => 'Verse 1 - Export Test Song'],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
|
||||
$verseSection = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$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(
|
||||
['name' => 'Chorus - Export Test Song'],
|
||||
['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->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 2]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
@ -120,13 +122,13 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
$importResponse->assertOk();
|
||||
|
||||
$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);
|
||||
|
||||
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
|
||||
$this->assertNotNull($defaultArr);
|
||||
|
||||
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
|
||||
$originalArrangementSections = $defaultArr->arrangementSections->sortBy('order')->values();
|
||||
$originalArrangements = $originalSong->arrangements;
|
||||
|
||||
$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();
|
||||
|
||||
$uniqueOriginalLabels = $originalArrangementLabels
|
||||
->map(fn ($al) => $al->label)
|
||||
$uniqueOriginalSections = $originalArrangementSections
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->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];
|
||||
$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);
|
||||
$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) {
|
||||
$reImportedSlide = $reImportedSlides[$slideIndex];
|
||||
|
|
@ -166,15 +168,15 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
$this->assertSame(
|
||||
$originalSlide->text_content,
|
||||
$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) {
|
||||
$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(
|
||||
$originalSlide->text_content_translated,
|
||||
$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);
|
||||
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
|
||||
|
||||
$originalGroupNames = $originalArrangement->arrangementLabels
|
||||
$originalGroupNames = $originalArrangement->arrangementSections
|
||||
->sortBy('order')
|
||||
->map(fn ($al) => $al->label?->name)
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
@ -284,7 +286,6 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
|
|||
'background_filename' => 'slides/background.jpg',
|
||||
]);
|
||||
$song = $this->createSongWithContent();
|
||||
$expectedPath = Storage::disk('public')->path('slides/background.jpg');
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||
$slides = $this->allParserSlides($parserSong);
|
||||
|
|
@ -292,7 +293,7 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
|
|||
$this->assertNotEmpty($slides);
|
||||
foreach ($slides as $slide) {
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame($expectedPath, $slide->getBackgroundMediaUrl());
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
$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');
|
||||
$slides = $this->allParserSlides(ProBundleReader::read($bundlePath)->getSong());
|
||||
$bundle = ProBundleReader::read($bundlePath);
|
||||
$slides = $this->allParserSlides($bundle->getSong());
|
||||
|
||||
$this->assertCount(2, $slides);
|
||||
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
|
||||
$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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
|
@ -72,8 +73,9 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void
|
|||
'is_default' => true,
|
||||
]);
|
||||
$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([
|
||||
'label_id' => $oldLabel->id,
|
||||
'song_section_id' => $oldSection->id,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -42,13 +42,8 @@
|
|||
});
|
||||
|
||||
test('migration rolls back cleanly', function (): void {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Destruktive Migration');
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@
|
|||
$arrangement = $song->arrangements()->where('is_default', true)->first();
|
||||
expect($arrangement)->not->toBeNull();
|
||||
expect($arrangement->name)->toBe('Normal');
|
||||
expect($arrangement->arrangementLabels)->toHaveCount(3);
|
||||
expect($arrangement->arrangementLabels->sortBy('order')->pluck('label.name')->toArray())
|
||||
expect($arrangement->arrangementSections)->toHaveCount(3);
|
||||
expect($arrangement->arrangementSections->sortBy('order')->pluck('section.label.name')->toArray())
|
||||
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
|
||||
});
|
||||
|
||||
|
|
@ -125,10 +125,11 @@
|
|||
test('show returns song with groups slides and arrangements', function () {
|
||||
$song = Song::factory()->create();
|
||||
$label = Label::factory()->create(['name' => 'Strophe 1']);
|
||||
$section = songSectionFor($song, $label);
|
||||
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -261,6 +262,8 @@
|
|||
$song = Song::factory()->create();
|
||||
$label1 = Label::factory()->create();
|
||||
$label2 = Label::factory()->create();
|
||||
$section1 = songSectionFor($song, $label1, 1);
|
||||
$section2 = songSectionFor($song, $label2, 2);
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -269,12 +272,12 @@
|
|||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label1->id,
|
||||
'song_section_id' => $section1->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label2->id,
|
||||
'song_section_id' => $section2->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -283,7 +286,7 @@
|
|||
|
||||
expect($clone->name)->toBe('Klone');
|
||||
expect($clone->is_default)->toBeFalse();
|
||||
expect($clone->arrangementLabels)->toHaveCount(2);
|
||||
expect($clone->arrangementLabels->pluck('label_id')->toArray())
|
||||
->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray());
|
||||
expect($clone->arrangementSections)->toHaveCount(2);
|
||||
expect($clone->arrangementSections->pluck('song_section_id')->toArray())
|
||||
->toBe($arrangement->arrangementSections->pluck('song_section_id')->toArray());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
'name' => 'Refrain',
|
||||
'color' => '#10B981',
|
||||
]);
|
||||
$section1 = songSectionFor($song, $label1, 1);
|
||||
$section2 = songSectionFor($song, $label2, 2);
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -35,13 +37,13 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label1->id,
|
||||
'song_section_id' => $section1->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label2->id,
|
||||
'song_section_id' => $section2->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,16 +22,17 @@
|
|||
'name' => 'Verse 1',
|
||||
'color' => '#3B82F6',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Amazing grace how sweet the sound',
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -74,28 +75,30 @@
|
|||
'name' => 'Refrain',
|
||||
'color' => '#10B981',
|
||||
]);
|
||||
$verseSection = songSectionFor($song, $verse, 1);
|
||||
$chorusSection = songSectionFor($song, $chorus, 2);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Großer Gott wir loben dich',
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $chorus->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Heilig heilig heilig',
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $chorus->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -120,9 +123,10 @@
|
|||
$label = Label::factory()->create([
|
||||
'name' => 'Verse 1',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Amazing grace how sweet the sound',
|
||||
'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang',
|
||||
|
|
@ -130,7 +134,7 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -198,16 +202,17 @@
|
|||
$label = Label::factory()->create([
|
||||
'name' => 'Strophe 1',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Großer Gott wir loben dich',
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -251,15 +256,17 @@
|
|||
'name' => 'Refrain',
|
||||
'color' => '#ef4444',
|
||||
]);
|
||||
$verseSection = songSectionFor($song, $verse, 1);
|
||||
$chorusSection = songSectionFor($song, $chorus, 2);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Strophe Text',
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $chorus->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Refrain Text',
|
||||
]);
|
||||
|
|
@ -271,13 +278,13 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $chorus->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -310,9 +317,10 @@
|
|||
$label = Label::factory()->create([
|
||||
'name' => 'Verse',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Original Text',
|
||||
'text_content_translated' => 'Translated Text',
|
||||
|
|
@ -324,7 +332,7 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
|
|||
191
tests/Feature/SongSectionControllerTest.php
Normal file
191
tests/Feature/SongSectionControllerTest.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -88,6 +88,8 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
|
|||
'name' => 'Refrain',
|
||||
'color' => '#10B981',
|
||||
]);
|
||||
$verseSection = songSectionFor($song, $verse, 1);
|
||||
$chorusSection = songSectionFor($song, $chorus, 2);
|
||||
|
||||
$normal = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -97,13 +99,13 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'label_id' => $verse->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'label_id' => $chorus->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,35 +38,37 @@ public function test_translate_page_response_contains_ordered_groups_and_slides(
|
|||
'name' => 'Strophe 1',
|
||||
'color' => '#0ea5e9',
|
||||
]);
|
||||
$sectionFirst = songSectionFor($song, $labelFirst, 1);
|
||||
$sectionLater = songSectionFor($song, $labelLater, 2);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $labelFirst->id,
|
||||
'song_section_id' => $sectionFirst->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $labelLater->id,
|
||||
'song_section_id' => $sectionLater->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $labelFirst->id,
|
||||
'song_section_id' => $sectionFirst->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Zeile A\nZeile B",
|
||||
'text_content_translated' => "Line A\nLine B",
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $labelFirst->id,
|
||||
'song_section_id' => $sectionFirst->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Zeile C\nZeile D\nZeile E",
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $labelLater->id,
|
||||
'song_section_id' => $sectionLater->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Refrain',
|
||||
'text_content_translated' => 'Chorus',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
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 App\Services\TranslationService;
|
||||
|
|
@ -70,23 +71,24 @@ function makeSongWithDefaultArrangement(): array
|
|||
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]);
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $arrangement->song_id, 'label_id' => $label->id],
|
||||
['order' => $arrangementOrder],
|
||||
);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $arrangementOrder,
|
||||
]);
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
SongSlide::factory()->create(array_merge(
|
||||
['label_id' => $label->id],
|
||||
$slide,
|
||||
));
|
||||
$section->slides()->create($slide);
|
||||
}
|
||||
|
||||
return $label;
|
||||
return $section;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Original 5\nOriginal 6",
|
||||
]);
|
||||
|
||||
$slide3 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 3,
|
||||
'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);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'label_id' => $label1->id,
|
||||
'song_section_id' => $label1->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line A\nLine B",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'label_id' => $label2->id,
|
||||
'song_section_id' => $label2->id,
|
||||
'order' => 1,
|
||||
'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);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1\nLine 2\nLine 3",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Line 4\nLine 5",
|
||||
]);
|
||||
|
|
@ -187,7 +189,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
|
||||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1);
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Line 1',
|
||||
]);
|
||||
|
|
@ -214,14 +216,14 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Original',
|
||||
'text_content_translated' => 'Übersetzt',
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 2,
|
||||
'text_content' => 'Original 2',
|
||||
'text_content_translated' => 'Übersetzt 2',
|
||||
|
|
@ -282,7 +284,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1);
|
||||
|
||||
$slide = SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1\nLine 2",
|
||||
]);
|
||||
|
|
@ -328,7 +330,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'label_id' => $label->id,
|
||||
'song_section_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Original',
|
||||
'text_content_translated' => 'Übersetzt',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
23
tests/fixtures/ccli/copy-icon-vers-author-trailing.txt
vendored
Normal file
23
tests/fixtures/ccli/copy-icon-vers-author-trailing.txt
vendored
Normal 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
|
||||
Loading…
Reference in a new issue