pp-planer/app/Services/SongService.php
Thorsten Bus ae42b48753 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.
2026-05-31 14:45:47 +02:00

97 lines
2.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\Label;
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 und Song-Sektionen existieren.
*
* @return Collection<int, SongSection>
*/
public function createDefaultGroups(Song $song): Collection
{
$defaults = [
['name' => 'Strophe 1', 'color' => '#3B82F6'],
['name' => 'Refrain', 'color' => '#10B981'],
['name' => 'Bridge', 'color' => '#F59E0B'],
];
$sections = collect();
foreach ($defaults as $index => $data) {
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
if ($existing === null) {
$existing = Label::create([
'name' => $data['name'],
'color' => $data['color'],
]);
}
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $existing->id],
['order' => $index + 1],
);
$section->update(['order' => $index + 1]);
$sections->push($section);
}
return $sections;
}
/**
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
*/
public function createDefaultArrangement(Song $song): SongArrangement
{
$arrangement = $song->arrangements()->create([
'name' => 'Normal',
'is_default' => true,
]);
$sections = $this->createDefaultGroups($song);
foreach ($sections->values() as $index => $section) {
$arrangement->arrangementSections()->create([
'song_section_id' => $section->id,
'order' => $index + 1,
]);
}
return $arrangement->load('arrangementSections.section.label');
}
/**
* Arrangement duplizieren mit neuem Namen.
*/
public function duplicateArrangement(SongArrangement $arrangement, string $name): SongArrangement
{
return DB::transaction(function () use ($arrangement, $name) {
$clone = $arrangement->replicate(['id', 'created_at', 'updated_at']);
$clone->name = $name;
$clone->is_default = false;
$clone->save();
foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
SongArrangementLabel::create([
'song_arrangement_id' => $clone->id,
'song_section_id' => $arrangementSection->song_section_id,
'order' => $arrangementSection->order,
]);
}
return $clone->load('arrangementSections.section.label');
});
}
}