Compare commits

..

No commits in common. "34c58ccc608ddbf6aabe1dfd45e61bb97b2ad49d" and "ff3484466bacec046960ac2f777aa7010ce261e7" have entirely different histories.

45 changed files with 277 additions and 2355 deletions

View file

@ -2,7 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -53,12 +52,7 @@ public function callback(): RedirectResponse
Auth::login($user, remember: true); Auth::login($user, remember: true);
$service = Service::nextUpcoming()->first(); return redirect()->intended(route('dashboard'));
$target = $service
? route('services.edit', $service->id)
: route('services.index');
return redirect()->intended($target);
} }
/** /**

View file

@ -1,59 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExportProFile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class ExportProFileController extends Controller
{
public function store(Request $request): JsonResponse
{
$request->validate([
'type' => ['required', Rule::in(['prefix', 'postfix'])],
'files' => ['required', 'array'],
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'],
], [
'files.*.extensions' => 'Nur .pro- und .probundle-Dateien sind erlaubt.',
]);
$type = $request->input('type');
$created = [];
foreach ($request->file('files') as $file) {
$storedPath = $file->store('export-pro-files/'.$type, 'local');
$maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0;
$record = ExportProFile::create([
'type' => $type,
'original_name' => $file->getClientOriginalName(),
'stored_path' => $storedPath,
'order' => $maxOrder + 1,
]);
$created[] = [
'id' => $record->id,
'original_name' => $record->original_name,
'order' => $record->order,
];
}
return response()->json([
'files' => $created,
'message' => count($created).' Datei(en) erfolgreich hochgeladen.',
], 201);
}
public function destroy(ExportProFile $exportProFile): JsonResponse
{
Storage::disk('local')->delete($exportProFile->stored_path);
$exportProFile->delete();
return response()->json([
'message' => 'Datei erfolgreich gelöscht.',
]);
}
}

View file

@ -27,7 +27,7 @@ public function importPro(Request $request): JsonResponse
} }
try { try {
$service = app(ProImportService::class); $service = new ProImportService;
$songs = $service->import($file); $songs = $service->import($file);
return response()->json([ return response()->json([

View file

@ -131,15 +131,11 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
$service->load([ $service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song', 'serviceSongs.song',
'serviceSongs.song.sections.label',
'serviceSongs.song.sections.slides',
'serviceSongs.song.arrangements.arrangementSections.section.label', 'serviceSongs.song.arrangements.arrangementSections.section.label',
'serviceSongs.arrangement', 'serviceSongs.arrangement',
'slides', 'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides', 'agendaItems.slides',
'agendaItems.serviceSong.song.sections.label',
'agendaItems.serviceSong.song.sections.slides',
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides', 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label', 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
'agendaItems.serviceSong.arrangement.arrangementSections.section.label', 'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
@ -231,15 +227,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
? $matcher->matchesAny($item->title, array_map('trim', explode(',', $sermonPatterns))) ? $matcher->matchesAny($item->title, array_map('trim', explode(',', $sermonPatterns)))
: false; : false;
// Inject `groups` into the nested song so the frontend ArrangementDialog
// can resolve all sections (including those not in the default arrangement).
if (isset($arr['service_song']['song'])) {
$song = $item->serviceSong?->song;
if ($song !== null) {
$arr['service_song']['song']['groups'] = $this->collectSongLabels($song)->toArray();
}
}
return $arr; return $arr;
}, $filteredItems); }, $filteredItems);
@ -296,7 +283,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
'background_is_own' => $backgroundIsOwn, 'background_is_own' => $backgroundIsOwn,
'moderator_name' => $service->moderator_name, 'moderator_name' => $service->moderator_name,
'preacher_name_override' => $service->preacher_name_override, 'preacher_name_override' => $service->preacher_name_override,
'worship_leader_name' => app(\App\Services\NameTagResolver::class)->worshipLeaderFor($service),
], ],
'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [ 'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
'id' => $ss->id, 'id' => $ss->id,
@ -452,24 +438,6 @@ public function download(Service $service): JsonResponse|BinaryFileResponse
} }
} }
public function downloadPreview(Service $service): JsonResponse|BinaryFileResponse
{
try {
$playlistService = app(\App\Services\PlaylistExportService::class);
$result = $playlistService->generatePlaylist($service, true);
$response = response()->download($result['path'], $result['filename']);
if ($result['skipped'] > 0) {
$response->headers->set('X-Skipped-Songs', (string) $result['skipped']);
}
return $response->deleteFileAfterSend(false);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse
{ {
$request->merge(['blockType' => $blockType]); $request->merge(['blockType' => $blockType]);
@ -513,28 +481,23 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
private function collectSongLabels(Song $song): \Illuminate\Support\Collection private function collectSongLabels(Song $song): \Illuminate\Support\Collection
{ {
// Build availableGroups from ALL song sections (not just the default arrangement), $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
// so every arrangement's referenced sections resolve to real slides on the frontend.
return $song->sections if ($defaultArr === null) {
->filter(fn ($section) => $section->label !== null) return collect();
->map(fn ($section) => [ }
'id' => $section->label->id,
'section_id' => $section->id, return $defaultArr->arrangementSections
'name' => $section->label->name,
'color' => $section->label->color,
'order' => $section->order,
'locked' => (bool) ($section->locked ?? false),
'slides' => $section->slides
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($slide) => [ ->map(fn ($arrangementSection) => [
'id' => $slide->id, 'id' => $arrangementSection->section?->label?->id,
'order' => $slide->order, 'section_id' => $arrangementSection->section?->id,
'text_content' => $slide->text_content, 'name' => $arrangementSection->section?->label?->name,
'text_content_translated' => $slide->text_content_translated, 'color' => $arrangementSection->section?->label?->color,
'notes' => $slide->notes ?? null, 'order' => $arrangementSection->order,
])->toArray(),
]) ])
->filter(fn ($group) => $group['id'] !== null)
->values(); ->values();
} }
} }

View file

@ -2,7 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\ExportProFile;
use App\Models\Label; use App\Models\Label;
use App\Models\Macro; use App\Models\Macro;
use App\Models\MacroAssignment; use App\Models\MacroAssignment;
@ -28,24 +27,10 @@ class SettingsController extends Controller
'namenseinblender_macro_uuid', 'namenseinblender_macro_uuid',
'namenseinblender_macro_collection_name', 'namenseinblender_macro_collection_name',
'namenseinblender_macro_collection_uuid', 'namenseinblender_macro_collection_uuid',
'namenseinblender_macro_id',
'song_prefix_label_id',
'song_postfix_label_id',
]; ];
public function index(): Response public function index(): Response
{ {
$copyright = Label::firstOrCreate(['name' => 'COPYRIGHT'], ['color' => '#24B34C']);
$blank = Label::firstOrCreate(['name' => 'BLANK'], ['color' => '#000000']);
if (Setting::get('song_prefix_label_id') === null) {
Setting::set('song_prefix_label_id', (string) $copyright->id);
}
if (Setting::get('song_postfix_label_id') === null) {
Setting::set('song_postfix_label_id', (string) $blank->id);
}
$settings = []; $settings = [];
foreach (self::AGENDA_KEYS as $key) { foreach (self::AGENDA_KEYS as $key) {
$settings[$key] = Setting::get($key); $settings[$key] = Setting::get($key);
@ -65,10 +50,6 @@ public function index(): Response
'at' => Setting::get('labels_last_imported_at'), 'at' => Setting::get('labels_last_imported_at'),
'filename' => Setting::get('labels_last_imported_filename'), 'filename' => Setting::get('labels_last_imported_filename'),
], ],
'export_pro_files' => [
'prefix' => ExportProFile::prefix()->orderBy('order')->get(['id', 'original_name', 'order']),
'postfix' => ExportProFile::postfix()->orderBy('order')->get(['id', 'original_name', 'order']),
],
]); ]);
} }

View file

@ -135,7 +135,6 @@ public function formatSongDetail(Song $song): array
'name' => $arrangementSection->section?->label?->name, 'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color, 'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order, 'order' => $arrangementSection->order,
'locked' => (bool) ($arrangementSection->section?->locked ?? false),
'slides' => $arrangementSection->section 'slides' => $arrangementSection->section
? $arrangementSection->section->slides ? $arrangementSection->section->slides
->sortBy('order') ->sortBy('order')

View file

@ -77,17 +77,11 @@ public function update(Request $request, Song $song, SongSection $section): Json
return response()->json(['message' => 'Sektion nicht gefunden.'], 404); return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
} }
if ($section->locked) {
return response()->json(['message' => 'Diese Sektion ist gesperrt und kann nicht bearbeitet werden.'], 422);
}
$data = $request->validate([ $data = $request->validate([
'slides' => ['required', 'array'], 'slides' => ['required', 'array'],
'slides.*.text_content' => ['required', 'string'], 'slides.*.text_content' => ['required', 'string'],
'slides.*.text_content_translated' => ['nullable', 'string'], 'slides.*.text_content_translated' => ['nullable', 'string'],
'order' => ['sometimes', 'integer'], 'order' => ['sometimes', 'integer'],
'label_name' => ['sometimes', 'string', 'max:255'],
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
], $this->validationMessages()); ], $this->validationMessages());
$responseSong = DB::transaction(function () use ($song, $section, $data): Song { $responseSong = DB::transaction(function () use ($song, $section, $data): Song {
@ -95,17 +89,6 @@ public function update(Request $request, Song $song, SongSection $section): Json
$section->update(['order' => $data['order']]); $section->update(['order' => $data['order']]);
} }
if (array_key_exists('label_name', $data) && trim($data['label_name']) !== '') {
$normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']);
$label = Label::firstOrCreate(
['name' => $normalizedLabelName],
['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR],
);
$section->update(['label_id' => $label->id]);
}
$this->replaceSlides($section, $data['slides']); $this->replaceSlides($section, $data['slides']);
$this->recomputeHasTranslation($song); $this->recomputeHasTranslation($song);
@ -124,10 +107,6 @@ public function destroy(Song $song, SongSection $section): JsonResponse
return response()->json(['message' => 'Sektion nicht gefunden.'], 404); return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
} }
if ($section->locked) {
return response()->json(['message' => 'Diese Sektion ist gesperrt und kann nicht gelöscht werden.'], 422);
}
$responseSong = DB::transaction(function () use ($song, $section): Song { $responseSong = DB::transaction(function () use ($song, $section): Song {
SongArrangementSection::query() SongArrangementSection::query()
->where('song_section_id', $section->id) ->where('song_section_id', $section->id)

View file

@ -1,33 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class ExportProFile extends Model
{
protected $fillable = [
'type',
'original_name',
'stored_path',
'order',
];
protected function casts(): array
{
return [
'order' => 'integer',
];
}
public function scopePrefix(Builder $query): Builder
{
return $query->where('type', 'prefix');
}
public function scopePostfix(Builder $query): Builder
{
return $query->where('type', 'postfix');
}
}

View file

@ -3,8 +3,6 @@
namespace App\Models; namespace App\Models;
use App\Services\AgendaMatcherService; use App\Services\AgendaMatcherService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -66,14 +64,6 @@ public function agendaItems(): HasMany
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order'); return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');
} }
/**
* Scope: nächster bevorstehender Gottesdienst (heute oder später), aufsteigend sortiert.
*/
public function scopeNextUpcoming(Builder $query): Builder
{
return $query->whereDate('date', '>=', Carbon::today())->orderBy('date');
}
/** /**
* Check finalization prerequisites and return warnings. * Check finalization prerequisites and return warnings.
* *

View file

@ -15,14 +15,12 @@ class SongSection extends Model
'song_id', 'song_id',
'label_id', 'label_id',
'order', 'order',
'locked',
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'order' => 'integer', 'order' => 'integer',
'locked' => 'boolean',
]; ];
} }

View file

@ -38,7 +38,6 @@ final class CcliImportService
public function __construct( public function __construct(
private readonly CcliPasteParser $parser, private readonly CcliPasteParser $parser,
private readonly SongPrefixPostfixService $songPrefixPostfixService,
) {} ) {}
/** @return array{song: Song, status: 'created'|'restored', warnings: string[]} */ /** @return array{song: Song, status: 'created'|'restored', warnings: string[]} */
@ -129,8 +128,6 @@ public function import(string $rawText, ?string $sourceUrl = null): array
]); ]);
} }
$this->songPrefixPostfixService->ensure($song);
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); $song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
ApiRequestLog::create([ ApiRequestLog::create([

View file

@ -21,12 +21,6 @@ public function moderatorFor(Service $service): ?string
return $override; return $override;
} }
// Prefer the person whose responsible role is "Moderation".
$byRole = $this->nameForRole($service, 'moderation');
if ($byRole !== null) {
return $byRole;
}
$firstAgendaItem = $service->agendaItems() $firstAgendaItem = $service->agendaItems()
->where('is_before_event', false) ->where('is_before_event', false)
->orderBy('sort_order') ->orderBy('sort_order')
@ -36,11 +30,6 @@ public function moderatorFor(Service $service): ?string
return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null; return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null;
} }
public function worshipLeaderFor(Service $service): ?string
{
return $this->nameForRole($service, 'lobpreis');
}
public function preacherFor(Service $service): ?string public function preacherFor(Service $service): ?string
{ {
$override = $this->filledString($service->preacher_name_override); $override = $this->filledString($service->preacher_name_override);
@ -53,12 +42,6 @@ public function preacherFor(Service $service): ?string
return $preacherName; return $preacherName;
} }
// Prefer the person whose responsible role is "Predigt".
$byRole = $this->nameForRole($service, 'predigt');
if ($byRole !== null) {
return $byRole;
}
$sermonItem = $service->agendaItems() $sermonItem = $service->agendaItems()
->where('is_before_event', false) ->where('is_before_event', false)
->whereNull('service_song_id') ->whereNull('service_song_id')
@ -70,29 +53,6 @@ public function preacherFor(Service $service): ?string
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null; return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null;
} }
/**
* Find the first visible agenda item carrying a responsible person whose
* role (persons[].service, e.g. "[Lobpreis]") matches $roleNeedle, and
* return that person's display name.
*/
private function nameForRole(Service $service, string $roleNeedle): ?string
{
$items = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
->orderBy('id')
->get();
foreach ($items as $item) {
$name = $this->nameFromResponsibleByRole($item->responsible, $roleNeedle);
if ($name !== null) {
return $name;
}
}
return null;
}
private function filledString(?string $value): ?string private function filledString(?string $value): ?string
{ {
$trimmed = trim((string) $value); $trimmed = trim((string) $value);
@ -106,17 +66,10 @@ private function namesFromResponsible(mixed $responsible): ?string
return null; return null;
} }
// Real CTS shape: { "text": "...", "persons": [ { "service": "[Lobpreis]", "person": { "title": "...", ... } } ] } $people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
if (array_key_exists('persons', $responsible)) {
$entries = $responsible['persons'];
} elseif (Arr::isAssoc($responsible)) {
$entries = [$responsible];
} else {
$entries = $responsible;
}
$names = collect($entries) $names = collect($people)
->map(fn (mixed $entry) => $this->nameFromResponsibleEntry($entry)) ->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
->filter() ->filter()
->values() ->values()
->all(); ->all();
@ -124,96 +77,28 @@ private function namesFromResponsible(mixed $responsible): ?string
return $names === [] ? null : implode(', ', $names); return $names === [] ? null : implode(', ', $names);
} }
/** private function nameFromResponsiblePerson(mixed $person): ?string
* Extract a person name from a responsible entry.
* Handles both the real CTS shape (entry has a 'person' sub-key) and the
* legacy/test shape (entry is a flat array with 'name'/'firstName'/'lastName' or a string).
*/
private function nameFromResponsibleEntry(mixed $entry): ?string
{ {
if (is_string($entry)) { if (is_string($person)) {
return $this->filledString($entry); return $this->filledString($person);
} }
if (! is_array($entry)) { if (! is_array($person)) {
return null; return null;
} }
// Real CTS shape: { "service": "[Lobpreis]", "person": { "title": "Kornelius Weiß", ... } } $name = $this->filledString($person['name'] ?? null);
if (isset($entry['person']) && is_array($entry['person'])) {
return $this->nameFromPersonNode($entry['person']);
}
// Legacy / test shape: { "name": "Anna Müller" } or { "firstName": "...", "lastName": "..." }
return $this->nameFromPersonNode($entry);
}
/** Extract display name from a person node (title preferred, then firstName+lastName). */
private function nameFromPersonNode(mixed $node): ?string
{
if (! is_array($node)) {
return null;
}
$title = $this->filledString($node['title'] ?? null);
if ($title !== null) {
return $title;
}
$name = $this->filledString($node['name'] ?? null);
if ($name !== null) { if ($name !== null) {
return $name; return $name;
} }
$domainAttrs = $node['domainAttributes'] ?? []; $firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? '';
$firstName = $this->filledString( $lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? '';
$node['firstName'] ?? $node['first_name'] ?? ($domainAttrs['firstName'] ?? null)
) ?? '';
$lastName = $this->filledString(
$node['lastName'] ?? $node['last_name'] ?? ($domainAttrs['lastName'] ?? null)
) ?? '';
$fullName = trim($firstName.' '.$lastName); $fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName; return $fullName === '' ? null : $fullName;
} }
/**
* Scan a responsible array for a person whose role (persons[].service)
* contains $roleNeedle (e.g. 'lobpreis', 'predigt', 'moderation').
* Returns the person's display name, or null if none found.
*/
private function nameFromResponsibleByRole(mixed $responsible, string $roleNeedle): ?string
{
if (! is_array($responsible) || $responsible === []) {
return null;
}
// Real CTS shape: { "persons": [ { "service": "[Lobpreis]", "person": { ... } } ] }
$entries = array_key_exists('persons', $responsible) ? $responsible['persons'] : [];
foreach ($entries as $entry) {
if (! is_array($entry)) {
continue;
}
$role = $this->normalizeRole($entry['service'] ?? '');
if ($role !== '' && str_contains($role, $roleNeedle)) {
$name = $this->nameFromResponsibleEntry($entry);
if ($name !== null) {
return $name;
}
}
}
return null;
}
/** Strip brackets, whitespace, and lowercase a role string for comparison. */
private function normalizeRole(string $role): string
{
return Str::lower(preg_replace('/[^a-zA-ZäöüÄÖÜß]/', '', $role) ?? '');
}
private function isSermonItem(ServiceAgendaItem $item): bool private function isSermonItem(ServiceAgendaItem $item): bool
{ {
$configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching')); $configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching'));

View file

@ -2,7 +2,6 @@
namespace App\Services; namespace App\Services;
use App\Models\Macro;
use App\Models\Setting; use App\Models\Setting;
class NameTagSlideBuilder class NameTagSlideBuilder
@ -19,30 +18,6 @@ public function buildPreacherSlide(string $name): ?array
public function build(string $name, string $title): ?array public function build(string $name, string $title): ?array
{ {
$macroId = Setting::get('namenseinblender_macro_id');
if ($macroId !== null && trim($macroId) !== '') {
$macro = Macro::with('collections')->find((int) $macroId);
if ($macro !== null) {
$collection = $macro->collections->first();
return [
'text' => $name."\n".$title,
'macro' => [
'name' => $macro->name,
'uuid' => $macro->uuid,
'collectionName' => $collection?->name ?? '--MAIN--',
'collectionUuid' => $collection?->uuid ?? null,
],
];
}
// macro_id is configured but the Macro record is missing — still return a slide
// without a macro so the name tag is not silently dropped.
return ['text' => $name."\n".$title];
}
$macroName = Setting::get('namenseinblender_macro_name'); $macroName = Setting::get('namenseinblender_macro_name');
if ($macroName === null || trim($macroName) === '') { if ($macroName === null || trim($macroName) === '') {

View file

@ -2,7 +2,6 @@
namespace App\Services; namespace App\Services;
use App\Models\ExportProFile;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceAgendaItem; use App\Models\ServiceAgendaItem;
use App\Models\Setting; use App\Models\Setting;
@ -11,12 +10,11 @@
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistGenerator; use ProPresenter\Parser\ProPlaylistGenerator;
use ZipArchive;
class PlaylistExportService class PlaylistExportService
{ {
/** @return array{path: string, filename: string, skipped: int} */ /** @return array{path: string, filename: string, skipped: int} */
public function generatePlaylist(Service $service, bool $preview = false): array public function generatePlaylist(Service $service): array
{ {
$agendaItems = ServiceAgendaItem::where('service_id', $service->id) $agendaItems = ServiceAgendaItem::where('service_id', $service->id)
->where('is_before_event', false) ->where('is_before_event', false)
@ -30,17 +28,17 @@ public function generatePlaylist(Service $service, bool $preview = false): array
->get(); ->get();
if ($agendaItems->isEmpty()) { if ($agendaItems->isEmpty()) {
return $this->generatePlaylistLegacy($service, $preview); return $this->generatePlaylistLegacy($service);
} }
return $this->generatePlaylistFromAgenda($service, $agendaItems, $preview); return $this->generatePlaylistFromAgenda($service, $agendaItems);
} }
/** /**
* @param Collection<int, ServiceAgendaItem> $agendaItems * @param Collection<int, ServiceAgendaItem> $agendaItems
* @return array{path: string, filename: string, skipped: int, temp_dir: string} * @return array{path: string, filename: string, skipped: int, temp_dir: string}
*/ */
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems): array
{ {
$informationSlides = Slide::where('type', 'information') $informationSlides = Slide::where('type', 'information')
->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date)) ->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date))
@ -67,9 +65,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$moderatorSlideData = $this->buildModeratorSlideData($service); $moderatorSlideData = $this->buildModeratorSlideData($service);
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id; $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
$realContentEmitted = false;
$keyvisualFallbackEmitted = false;
foreach ($agendaItems as $item) { foreach ($agendaItems as $item) {
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
$this->writeProAndEmbed( $this->writeProAndEmbed(
@ -124,8 +119,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
'name' => $song->title, 'name' => $song->title,
'path' => $proFilename, 'path' => $proFilename,
]; ];
$realContentEmitted = true;
} else { } else {
$skippedUnmatched++; $skippedUnmatched++;
} }
@ -139,7 +132,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles); $this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
} }
$countBefore = count($playlistItems);
$label = $item->title ?: 'Folien'; $label = $item->title ?: 'Folien';
$this->addSlidesFromCollection( $this->addSlidesFromCollection(
$item->slides, $item->slides,
@ -152,15 +144,10 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$this->backgroundPartTypeForAgendaItem($item), $this->backgroundPartTypeForAgendaItem($item),
); );
if (count($playlistItems) > $countBefore) {
$realContentEmitted = true;
}
continue; continue;
} }
if (! $this->isNameTagAgendaItem($item)) { if (! $this->isNameTagAgendaItem($item)) {
if ($realContentEmitted && ! $keyvisualFallbackEmitted) {
$this->addKeyVisualFallbackPresentation( $this->addKeyVisualFallbackPresentation(
$item, $item,
$service, $service,
@ -168,13 +155,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$playlistItems, $playlistItems,
$embeddedFiles, $embeddedFiles,
); );
$keyvisualFallbackEmitted = true;
} else {
$this->addHeadlineItem(
$item,
$playlistItems,
);
}
} }
} }
@ -200,20 +180,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
} }
$this->injectExportProFiles($playlistItems, $embeddedFiles);
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); $playlistName = $service->title.' - '.$dateFormatted;
$outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist';
if ($preview) {
$exportStamp = now()->format('Y-m-d_H-i');
$playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')';
$outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist';
} else {
$playlistName = $dateFormatted.' '.$service->title;
$outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist';
}
$outputPath = $tempDir.'/'.$outputFilename; $outputPath = $tempDir.'/'.$outputFilename;
$this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles); $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles);
@ -232,7 +201,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
* *
* @return array{path: string, filename: string, skipped: int, temp_dir: string} * @return array{path: string, filename: string, skipped: int, temp_dir: string}
*/ */
private function generatePlaylistLegacy(Service $service, bool $preview = false): array private function generatePlaylistLegacy(Service $service): array
{ {
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label'); $service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
@ -308,20 +277,9 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
} }
$this->injectExportProFiles($playlistItems, $embeddedFiles);
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); $playlistName = $service->title.' - '.$dateFormatted;
$outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist';
if ($preview) {
$exportStamp = now()->format('Y-m-d_H-i');
$playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')';
$outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist';
} else {
$playlistName = $dateFormatted.' '.$service->title;
$outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist';
}
$outputPath = $tempDir.'/'.$outputFilename; $outputPath = $tempDir.'/'.$outputFilename;
$this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles); $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles);
@ -513,118 +471,6 @@ private function addKeyVisualFallbackPresentation(
]; ];
} }
private function addHeadlineItem(
ServiceAgendaItem $item,
array &$playlistItems,
): void {
$playlistItems[] = [
'type' => 'header',
'name' => $item->title ?: 'Ablaufpunkt',
'color' => [0.5, 0.5, 0.5, 1.0],
];
}
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
{
$prefixEmbedded = [];
$prefixItems = $this->buildExportProItems(
ExportProFile::prefix()->orderBy('order')->get(),
'PREFIX',
$prefixEmbedded,
);
$postfixEmbedded = [];
$postfixItems = $this->buildExportProItems(
ExportProFile::postfix()->orderBy('order')->get(),
'POSTFIX',
$postfixEmbedded,
);
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
$embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded);
}
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
private function buildExportProItems(\Illuminate\Support\Collection $files, string $marker, array &$embedded): array
{
$items = [];
foreach ($files as $file) {
if (! Storage::disk('local')->exists($file->stored_path)) {
continue;
}
$ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION));
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
$embeddedProName = $marker.'_'.$file->order.'_'.$safeBase.'.pro';
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
if ($ext === 'probundle') {
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
$zip = new ZipArchive;
if ($zip->open($storedAbsPath) !== true) {
continue;
}
$proBytes = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
if ($entryName === false) {
continue;
}
if (str_ends_with($entryName, '/')) {
continue;
}
$entryExt = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
$entryBytes = $zip->getFromIndex($i);
if ($entryBytes === false) {
continue;
}
if ($entryExt === 'pro') {
$proBytes = $entryBytes;
} else {
$mediaName = basename($entryName);
if (! isset($embedded[$mediaName])) {
$embedded[$mediaName] = $entryBytes;
}
}
}
$zip->close();
if ($proBytes === null) {
continue;
}
$embedded[$embeddedProName] = $proBytes;
$items[] = [
'type' => 'presentation',
'name' => $displayName,
'path' => $embeddedProName,
];
} else {
$bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) {
continue;
}
$embedded[$embeddedProName] = $bytes;
$items[] = [
'type' => 'presentation',
'name' => $displayName,
'path' => $embeddedProName,
];
}
}
return $items;
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{ {
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);

View file

@ -54,24 +54,6 @@ private function buildGroups(Song $song, ?Service $service = null): array
$seenSectionIds = []; $seenSectionIds = [];
$background = $this->backgroundData($service); $background = $this->backgroundData($service);
// Pre-compute the total slide count across the whole song (same filtering as the loop below)
// so that 'first_slide' and 'last_slide' macro positions refer to the song's very first/last slide.
$totalSlidesInSong = 0;
$seenForCount = [];
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null || $section->label === null) {
continue;
}
if (in_array($section->id, $seenForCount, true)) {
continue;
}
$seenForCount[] = $section->id;
$totalSlidesInSong += $section->slides->count();
}
$globalSlideIndex = 0;
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section; $section = $arrangementSection->section;
$label = $section?->label; $label = $section?->label;
@ -87,8 +69,9 @@ private function buildGroups(Song $song, ?Service $service = null): array
$slides = []; $slides = [];
$sectionSlides = $section->slides->sortBy('order')->values(); $sectionSlides = $section->slides->sortBy('order')->values();
$totalSlides = $sectionSlides->count();
foreach ($sectionSlides as $slide) { foreach ($sectionSlides as $slideIndex => $slide) {
$slideData = ['text' => $slide->text_content ?? '']; $slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) { if ($slide->text_content_translated) {
@ -103,7 +86,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
$macros = $this->macroResolutionService->macrosForSlide( $macros = $this->macroResolutionService->macrosForSlide(
$service, $service,
'song', 'song',
['index' => $globalSlideIndex, 'total' => $totalSlidesInSong, 'label_id' => $label->id], ['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id],
); );
if (! empty($macros)) { if (! empty($macros)) {
@ -113,7 +96,6 @@ private function buildGroups(Song $song, ?Service $service = null): array
} }
$slides[] = $slideData; $slides[] = $slideData;
$globalSlideIndex++;
} }
$groups[] = [ $groups[] = [

View file

@ -16,10 +16,6 @@
class ProImportService class ProImportService
{ {
public function __construct(
private readonly SongPrefixPostfixService $songPrefixPostfixService,
) {}
/** @return Song[] */ /** @return Song[] */
public function import(UploadedFile $file): array public function import(UploadedFile $file): array
{ {
@ -178,8 +174,6 @@ private function upsertSong(ProSong $proSong): Song
} }
} }
$this->songPrefixPostfixService->ensure($song);
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
} }

View file

@ -11,10 +11,6 @@
class SongMatchingService class SongMatchingService
{ {
public function __construct(
private readonly SongPrefixPostfixService $songPrefixPostfixService,
) {}
public function autoMatch(ServiceSong $serviceSong): bool public function autoMatch(ServiceSong $serviceSong): bool
{ {
if ($serviceSong->song_id !== null) { if ($serviceSong->song_id !== null) {
@ -80,8 +76,6 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void
} }
$serviceSong->update($updateData); $serviceSong->update($updateData);
$this->songPrefixPostfixService->ensure($song);
} }
/** /**

View file

@ -1,133 +0,0 @@
<?php
namespace App\Services;
use App\Models\Label;
use App\Models\Setting;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use Illuminate\Support\Facades\DB;
class SongPrefixPostfixService
{
public function ensure(Song $song): void
{
DB::transaction(function () use ($song): void {
$prefixLabelId = $this->resolvePrefixLabelId();
$postfixLabelId = $this->resolvePostfixLabelId();
$prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0);
$postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX);
$arrangement = $this->resolveDefaultArrangement($song);
$this->ensureSectionInArrangement($arrangement, $prefixSection, 'first');
$this->ensureSectionInArrangement($arrangement, $postfixSection, 'last');
});
}
private function resolvePrefixLabelId(): int
{
$id = Setting::get('song_prefix_label_id');
if ($id !== null && $id !== '') {
return (int) $id;
}
$label = Label::firstOrCreate(
['name' => 'COPYRIGHT'],
['color' => '#24B34C'],
);
Setting::set('song_prefix_label_id', (string) $label->id);
return $label->id;
}
private function resolvePostfixLabelId(): int
{
$id = Setting::get('song_postfix_label_id');
if ($id !== null && $id !== '') {
return (int) $id;
}
$label = Label::firstOrCreate(
['name' => 'BLANK'],
['color' => '#000000'],
);
Setting::set('song_postfix_label_id', (string) $label->id);
return $label->id;
}
private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection
{
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $labelId],
['order' => $orderHint, 'locked' => true],
);
$section->update(['locked' => true]);
$section->slides()->delete();
return $section;
}
private function resolveDefaultArrangement(Song $song): SongArrangement
{
return SongArrangement::firstOrCreate(
['song_id' => $song->id, 'name' => 'normal'],
['is_default' => true],
);
}
private function ensureSectionInArrangement(
SongArrangement $arrangement,
SongSection $section,
string $position,
): void {
$existing = SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->where('song_section_id', $section->id)
->first();
if ($position === 'first') {
if ($existing === null) {
SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->increment('order');
SongArrangementSection::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 0,
]);
} else {
if ($existing->order !== 0) {
SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->where('id', '!=', $existing->id)
->where('order', '<', $existing->order)
->increment('order');
$existing->update(['order' => 0]);
}
}
} else {
$maxOrder = SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->where('song_section_id', '!=', $section->id)
->max('order') ?? 0;
if ($existing === null) {
SongArrangementSection::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => $maxOrder + 1,
]);
} else {
$existing->update(['order' => $maxOrder + 1]);
}
}
}
}

View file

@ -27,9 +27,6 @@ chmod -R 775 storage bootstrap/cache database 2>/dev/null || true
rm -f /app/public/hot rm -f /app/public/hot
echo "[boot] Syncing pre-built Vite assets to bind-mounted public/ ..." echo "[boot] Syncing pre-built Vite assets to bind-mounted public/ ..."
# Alte gehashte Assets entfernen, damit sich im bind-mounteten public/build
# kein Muell aus frueheren Builds ansammelt (cp -r merged nur, loescht nie).
rm -rf /app/public/build
cp -r /app/public-build/* /app/public/ 2>/dev/null || true cp -r /app/public-build/* /app/public/ 2>/dev/null || true
# Create RELATIVE storage symlink (public/storage → ../storage/app/public). # Create RELATIVE storage symlink (public/storage → ../storage/app/public).

View file

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('export_pro_files', function (Blueprint $table) {
$table->id();
$table->string('type');
$table->string('original_name');
$table->string('stored_path');
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('export_pro_files');
}
};

View file

@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('song_sections', function (Blueprint $table) {
$table->boolean('locked')->default(false)->after('order');
});
}
public function down(): void
{
Schema::table('song_sections', function (Blueprint $table) {
$table->dropColumn('locked');
});
}
};

View file

@ -40,21 +40,9 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
worshipLeaderName: {
type: String,
default: null,
},
serviceSongArrangementId: {
type: Number,
default: null,
},
defaultArrangementId: {
type: Number,
default: null,
},
}) })
const emit = defineEmits(['close', 'arrangement-selected', 'unassigned']) const emit = defineEmits(['close', 'arrangement-selected'])
/* ── Song assignment (unmatched songs) ── */ /* ── Song assignment (unmatched songs) ── */
@ -130,19 +118,6 @@ async function assignSong() {
} }
} }
async function unassignSong() {
if (!confirm('Diese Songzuordnung wirklich aufheben?')) return
try {
await window.axios.post(`/api/service-songs/${props.serviceSongId}/unassign`)
emit('unassigned')
emit('close')
} catch {
// Silently ignore parent will reload on close
emit('close')
}
}
/* ── State ── */ /* ── State ── */
// Virtual MASTER arrangement always first, computed from availableGroups // Virtual MASTER arrangement always first, computed from availableGroups
@ -297,71 +272,6 @@ function closeOnEscape(e) {
} }
} }
async function selectArrangementForServiceSong(arrangementId) {
try {
await window.axios.patch(`/api/service-songs/${props.serviceSongId}`, {
song_arrangement_id: arrangementId,
})
currentArrangementId.value = arrangementId
} catch {
// Ignore user can select manually
}
}
async function ensureLeaderArrangement() {
if (!props.worshipLeaderName || isUnmatched.value) return
// Only auto-act if the user hasn't manually chosen an arrangement
const isUnchanged =
props.serviceSongArrangementId === null ||
props.serviceSongArrangementId === props.defaultArrangementId
if (!isUnchanged) return
const leaderName = props.worshipLeaderName.trim()
if (!leaderName) return
// Check if an arrangement with this name already exists
const existing = props.arrangements.find(
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
)
if (existing) {
await selectArrangementForServiceSong(existing.id)
return
}
// Create by cloning the default arrangement
const defaultArr = props.arrangements.find((a) => a.is_default) ?? props.arrangements[0]
if (!defaultArr) return
try {
pendingAutoSelect.value = true
router.post(
`/arrangements/${defaultArr.id}/clone`,
{ name: leaderName },
{
preserveScroll: true,
onSuccess: async () => {
router.reload({
preserveScroll: true,
onSuccess: async () => {
const created = props.arrangements.find(
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
)
if (created) {
await selectArrangementForServiceSong(created.id)
}
},
})
},
},
)
} catch {
// Ignore
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', closeOnEscape) document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick) document.addEventListener('click', onBodyClick)
@ -369,9 +279,6 @@ onMounted(() => {
// For unmatched songs: show search results immediately (prefilled with the song name). // For unmatched songs: show search results immediately (prefilled with the song name).
if (isUnmatched.value) { if (isUnmatched.value) {
dropdownOpen.value = true dropdownOpen.value = true
} else {
// For matched songs: auto-create/select worship-leader arrangement if un-changed.
ensureLeaderArrangement()
} }
}) })
@ -382,13 +289,8 @@ onUnmounted(() => {
/* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */ /* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */
function leaderPrefix() {
return props.worshipLeaderName?.trim() ? `${props.worshipLeaderName.trim()} - ` : ''
}
function createArrangement() { function createArrangement() {
const defaultName = leaderPrefix() const name = window.prompt('Name für das neue Arrangement:')
const name = window.prompt('Name für das neue Arrangement:', defaultName)
if (!name?.trim()) return if (!name?.trim()) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
@ -404,8 +306,7 @@ function cloneArrangement() {
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order) // Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
if (isMasterSelected.value) { if (isMasterSelected.value) {
const defaultName = leaderPrefix() || 'MASTER Kopie' const name = window.prompt('Name für das geklonte Arrangement:', 'MASTER Kopie')
const name = window.prompt('Name für das geklonte Arrangement:', defaultName)
if (!name?.trim()) return if (!name?.trim()) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
@ -417,10 +318,7 @@ function cloneArrangement() {
return return
} }
const defaultName = leaderPrefix() const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`)
? `${leaderPrefix()}${currentArrangement.value?.name ?? ''}`
: `${currentArrangement.value?.name ?? ''} Kopie`
const name = window.prompt('Name für das geklonte Arrangement:', defaultName)
if (!name?.trim()) return if (!name?.trim()) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, { router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, {
@ -599,15 +497,6 @@ function closeOnBackdrop(e) {
Löschen Löschen
</button> </button>
<button
data-testid="arrangement-unassign-button"
type="button"
class="rounded-md border border-orange-300 bg-orange-50 px-3 py-2 text-sm font-semibold text-orange-700 shadow hover:bg-orange-100"
@click="unassignSong"
>
Song entfernen / neu zuordnen
</button>
<button <button
data-testid="arrangement-dialog-close-btn" data-testid="arrangement-dialog-close-btn"
type="button" type="button"

View file

@ -8,7 +8,6 @@ const props = defineProps({
serviceSongId: { type: Number, default: null }, serviceSongId: { type: Number, default: null },
pairWithSongId: { type: Number, default: null }, pairWithSongId: { type: Number, default: null },
prefilledText: { type: String, default: null }, prefilledText: { type: String, default: null },
autoEdit: { type: Boolean, default: false },
}) })
const emit = defineEmits(['close', 'imported', 'paired', 'edit-song']) const emit = defineEmits(['close', 'imported', 'paired', 'edit-song'])
@ -48,9 +47,6 @@ async function doPreview() {
error.value = data.message || 'Fehler beim Verarbeiten des Textes.' error.value = data.message || 'Fehler beim Verarbeiten des Textes.'
} else { } else {
preview.value = data preview.value = data
if (props.autoEdit && (props.mode === 'songdb' || props.mode === 'service-form')) {
await doImport('edit')
}
} }
} catch { } catch {
error.value = 'Netzwerkfehler. Bitte versuche es erneut.' error.value = 'Netzwerkfehler. Bitte versuche es erneut.'

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref, watch, onMounted, onUnmounted } from 'vue' import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue' import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
const props = defineProps({ const props = defineProps({
@ -21,7 +22,6 @@ const loading = ref(false)
const error = ref(null) const error = ref(null)
const songData = ref(null) const songData = ref(null)
const sectionDrafts = ref({}) const sectionDrafts = ref({})
const sectionLabels = ref({})
const title = ref('') const title = ref('')
const ccliId = ref('') const ccliId = ref('')
@ -30,63 +30,11 @@ const showAddSectionForm = ref(false)
const newSectionLabel = ref('') const newSectionLabel = ref('')
const newSectionText = ref('') const newSectionText = ref('')
const sectionLabelDropdownOpen = ref(false) const sectionLabelDropdownOpen = ref(false)
const headlineDropdownFor = ref(null)
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
let savedTimeout = null let savedTimeout = null
const sectionSaveDebouncers = new Map()
/* ── Pristine snapshot for dirty tracking ── */
const pristine = ref(null)
function snapshotPristine() {
pristine.value = {
title: title.value,
ccliId: ccliId.value,
copyrightText: copyrightText.value,
sections: JSON.parse(JSON.stringify(sectionDrafts.value)),
labels: { ...sectionLabels.value },
}
}
const isDirty = computed(() => {
if (!pristine.value) return false
if (
title.value !== pristine.value.title ||
ccliId.value !== pristine.value.ccliId ||
copyrightText.value !== pristine.value.copyrightText
) {
return true
}
const keys = new Set([
...Object.keys(pristine.value.sections),
...Object.keys(sectionDrafts.value),
])
for (const key of keys) {
const before = pristine.value.sections[key] ?? { text: '', translated: '' }
const now = sectionDrafts.value[key] ?? { text: '', translated: '' }
if ((before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')) {
return true
}
if ((pristine.value.labels[key] ?? '') !== (sectionLabels.value[key] ?? '')) {
return true
}
}
return false
})
/* ── CSRF (read fresh on every request to avoid stale token after CCLI import) ── */
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || ''
}
/* ── Save status ── */ /* ── Save status ── */
@ -124,19 +72,15 @@ function sectionKey(group) {
function setSectionDrafts(data) { function setSectionDrafts(data) {
const drafts = {} const drafts = {}
const labels = {}
;(data?.groups ?? []).forEach((group) => { ;(data?.groups ?? []).forEach((group) => {
const key = sectionKey(group) drafts[sectionKey(group)] = {
drafts[key] = {
text: slidesToText(group.slides, 'text_content'), text: slidesToText(group.slides, 'text_content'),
translated: slidesToText(group.slides, 'text_content_translated'), translated: slidesToText(group.slides, 'text_content_translated'),
} }
labels[key] = group.name ?? ''
}) })
sectionDrafts.value = drafts sectionDrafts.value = drafts
sectionLabels.value = labels
} }
function draftFor(group) { function draftFor(group) {
@ -175,8 +119,6 @@ const fetchSong = async () => {
title.value = json.data.title ?? '' title.value = json.data.title ?? ''
ccliId.value = json.data.ccli_id ?? '' ccliId.value = json.data.ccli_id ?? ''
copyrightText.value = json.data.copyright_text ?? '' copyrightText.value = json.data.copyright_text ?? ''
snapshotPristine()
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
} finally { } finally {
@ -194,119 +136,67 @@ watch(
if (!isVisible) { if (!isVisible) {
songData.value = null songData.value = null
sectionDrafts.value = {} sectionDrafts.value = {}
sectionLabels.value = {}
pristine.value = null
error.value = null error.value = null
showAddSectionForm.value = false showAddSectionForm.value = false
newSectionLabel.value = '' newSectionLabel.value = ''
newSectionText.value = '' newSectionText.value = ''
headlineDropdownFor.value = null
} }
}, },
) )
/* ── Save metadata ── */ /* ── Auto-save metadata (fetch-based, 500ms debounce for text) ── */
const performSave = async (data) => {
if (!props.songId) return
startSaving()
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const performSaveMetadata = async () => {
const response = await fetch(`/api/songs/${props.songId}`, { const response = await fetch(`/api/songs/${props.songId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': csrfToken,
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ body: JSON.stringify(data),
title: title.value,
ccli_id: ccliId.value || null,
copyright_text: copyrightText.value || null,
}),
}) })
if (!response.ok) { if (!response.ok) {
throw new Error('Speichern fehlgeschlagen') throw new Error('Speichern fehlgeschlagen')
} }
}
/* ── Save all changes (manual Save button) ── */
async function saveAll() {
if (!props.songId || !isDirty.value || saving.value) return
startSaving()
try {
const metadataDirty =
title.value !== pristine.value.title ||
ccliId.value !== pristine.value.ccliId ||
copyrightText.value !== pristine.value.copyrightText
if (metadataDirty) {
await performSaveMetadata()
}
for (const group of songData.value?.groups ?? []) {
if (group.locked) continue
const key = sectionKey(group)
const before = pristine.value.sections[key] ?? { text: '', translated: '' }
const now = sectionDrafts.value[key] ?? { text: '', translated: '' }
const labelBefore = pristine.value.labels[key] ?? ''
const labelNow = sectionLabels.value[key] ?? ''
const textChanged =
(before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')
const labelChanged = labelBefore !== labelNow
if (textChanged || labelChanged) {
await persistSection(key, labelChanged ? labelNow : null)
}
}
await fetchSong()
finishSaving() finishSaving()
emit('updated') emit('updated')
} catch { } catch {
stopSaving() stopSaving()
error.value = 'Speichern fehlgeschlagen.'
} }
} }
async function persistSection(sectionId, labelName) { const buildPayload = () => ({
const body = { title: title.value,
slides: buildSectionSlides(sectionId), ccli_id: ccliId.value || null,
} copyright_text: copyrightText.value || null,
})
if (labelName) { // 500ms debounce for text inputs
body.label_name = labelName const debouncedSave = useDebounceFn((data) => {
} performSave(data)
}, 500)
const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), { const onTextInput = () => {
method: 'PATCH', debouncedSave(buildPayload())
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error('Sektion konnte nicht gespeichert werden.')
}
} }
/* ── Close handling (guard unsaved changes) ── */ // Immediate save on blur (cancel pending debounce)
const onFieldBlur = () => {
function requestClose() { debouncedSave.cancel?.()
if (isDirty.value && !window.confirm('Es gibt ungespeicherte Änderungen. Wirklich schließen?')) { performSave(buildPayload())
return
}
emit('close')
} }
/* ── Arrangement props ── */ /* ── Arrangement props ── */
@ -404,36 +294,54 @@ function buildSectionSlides(sectionId) {
})) }))
} }
/* ── Section headline combobox ── */ async function saveSection(sectionId) {
if (!props.songId || !sectionDrafts.value[sectionId]) return
function headlineDraftFor(group) { startSaving()
return sectionLabels.value[sectionKey(group)] ?? group.name ?? ''
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 setHeadline(group, name) { function onSectionInput(sectionId) {
sectionLabels.value[sectionKey(group)] = name if (!sectionSaveDebouncers.has(sectionId)) {
sectionSaveDebouncers.set(sectionId, useDebounceFn(() => {
saveSection(sectionId)
}, 600))
}
sectionSaveDebouncers.get(sectionId)()
} }
function selectHeadline(group, name) { function onSectionBlur(sectionId) {
setHeadline(group, name) sectionSaveDebouncers.get(sectionId)?.cancel?.()
headlineDropdownFor.value = null saveSection(sectionId)
}
function openHeadlineDropdown(group) {
headlineDropdownFor.value = sectionKey(group)
}
function closeHeadlineDropdown() {
setTimeout(() => {
headlineDropdownFor.value = null
}, 150)
}
function filteredHeadlineOptions(group) {
const term = (sectionLabels.value[sectionKey(group)] ?? '').trim().toLowerCase()
if (term === '') return sectionLabelOptions.value
return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term))
} }
async function deleteSection(sectionId) { async function deleteSection(sectionId) {
@ -442,12 +350,13 @@ async function deleteSection(sectionId) {
startSaving() startSaving()
try { try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), { const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': csrfToken,
}, },
credentials: 'same-origin', credentials: 'same-origin',
}) })
@ -459,7 +368,6 @@ async function deleteSection(sectionId) {
const json = await response.json() const json = await response.json()
songData.value = json.data songData.value = json.data
setSectionDrafts(json.data) setSectionDrafts(json.data)
snapshotPristine()
finishSaving() finishSaving()
emit('updated') emit('updated')
} catch { } catch {
@ -473,13 +381,14 @@ async function addSection() {
startSaving() startSaving()
try { try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const response = await fetch(route('songs.sections.store', props.songId), { const response = await fetch(route('songs.sections.store', props.songId), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': csrfToken,
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ body: JSON.stringify({
@ -508,13 +417,13 @@ async function addSection() {
const closeOnEscape = (e) => { const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) { if (e.key === 'Escape' && props.show) {
requestClose() emit('close')
} }
} }
const closeOnBackdrop = (e) => { const closeOnBackdrop = (e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
requestClose() emit('close')
} }
} }
@ -634,7 +543,7 @@ onUnmounted(() => {
data-testid="song-edit-modal-close-button" data-testid="song-edit-modal-close-button"
type="button" type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600" class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="requestClose" @click="emit('close')"
> >
<svg <svg
class="h-5 w-5" class="h-5 w-5"
@ -739,6 +648,8 @@ onUnmounted(() => {
type="text" type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Songtitel eingeben…" placeholder="Songtitel eingeben…"
@input="onTextInput"
@blur="onFieldBlur"
> >
</div> </div>
@ -756,6 +667,8 @@ onUnmounted(() => {
type="text" type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="z.B. 123456" placeholder="z.B. 123456"
@input="onTextInput"
@blur="onFieldBlur"
> >
</div> </div>
@ -802,6 +715,8 @@ onUnmounted(() => {
rows="3" rows="3"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Copyright-Informationen…" placeholder="Copyright-Informationen…"
@input="onTextInput"
@blur="onFieldBlur"
/> />
</div> </div>
</div> </div>
@ -816,7 +731,7 @@ onUnmounted(() => {
</h3> </h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
Leerzeilen trennen einzelne Folien. Klicke Speichern", um Deine Änderungen zu sichern. Leerzeilen trennen einzelne Folien. Änderungen speichern automatisch.
</p> </p>
</div> </div>
@ -920,60 +835,17 @@ onUnmounted(() => {
v-for="group in songData.groups" v-for="group in songData.groups"
:key="group.section_id ?? group.id" :key="group.section_id ?? group.id"
data-testid="section-block" data-testid="section-block"
:class="[ class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
'rounded-lg border p-4 shadow-sm',
group.locked ? 'border-gray-100 bg-gray-50' : 'border-gray-200 bg-white',
]"
> >
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<div class="relative flex items-center gap-2">
<span <span
class="inline-block h-4 w-4 shrink-0 rounded-full shadow-sm" class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white shadow-sm"
:style="{ backgroundColor: group.color ?? '#6b7280' }" :style="{ backgroundColor: group.color ?? '#6b7280' }"
/>
<span
v-if="group.locked"
data-testid="section-headline-locked"
class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-sm font-semibold text-gray-500"
> >
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{{ group.name }} {{ group.name }}
</span> </span>
<template v-else>
<input
data-testid="section-headline-input"
:value="headlineDraftFor(group)"
type="text"
autocomplete="off"
class="w-48 rounded-md border-gray-300 text-sm font-semibold shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Abschnitt wählen…"
@input="setHeadline(group, $event.target.value)"
@focus="openHeadlineDropdown(group)"
@blur="closeHeadlineDropdown"
>
<div
v-if="headlineDropdownFor === (group.section_id ?? group.id) && filteredHeadlineOptions(group).length > 0"
data-testid="section-headline-dropdown"
class="absolute left-6 top-full z-30 mt-1 max-h-56 w-48 overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="labelName in filteredHeadlineOptions(group)"
:key="labelName"
type="button"
data-testid="section-headline-option"
class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50"
@mousedown.prevent="selectHeadline(group, labelName)"
>
{{ labelName }}
</button>
</div>
</template>
</div>
<button <button
v-if="!group.locked"
data-testid="section-delete-button" data-testid="section-delete-button"
type="button" type="button"
class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700" class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700"
@ -986,20 +858,7 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div <div :class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''">
v-if="group.locked"
class="flex items-center gap-1.5 text-xs text-gray-400"
>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Gesperrt wird automatisch verwaltet
</div>
<div
v-else
:class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''"
>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-700"> <label class="mb-1 block text-sm font-medium text-gray-700">
Originaltext Originaltext
@ -1010,6 +869,8 @@ onUnmounted(() => {
rows="6" rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext…" placeholder="Folientext…"
@input="onSectionInput(group.section_id ?? group.id)"
@blur="onSectionBlur(group.section_id ?? group.id)"
/> />
</div> </div>
@ -1023,6 +884,8 @@ onUnmounted(() => {
rows="6" rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Übersetzter Folientext…" placeholder="Übersetzter Folientext…"
@input="onSectionInput(group.section_id ?? group.id)"
@blur="onSectionBlur(group.section_id ?? group.id)"
/> />
</div> </div>
</div> </div>
@ -1044,31 +907,6 @@ onUnmounted(() => {
/> />
</div> </div>
</div> </div>
<!-- Footer: Save / Close -->
<div
v-if="songData"
class="flex items-center justify-end gap-3 rounded-b-xl border-t border-gray-200 bg-gray-50 px-6 py-4"
>
<button
data-testid="song-edit-close"
type="button"
class="rounded-md px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200"
@click="requestClose"
>
Schließen
</button>
<button
data-testid="song-edit-save"
type="button"
class="rounded-md bg-amber-500 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDirty || saving"
@click="saveAll"
>
Speichern
</button>
</div>
</div> </div>
</div> </div>
</Transition> </Transition>

View file

@ -315,38 +315,6 @@ async function downloadService() {
showToast('Fehler beim Herunterladen.', 'warning') showToast('Fehler beim Herunterladen.', 'warning')
} }
} }
async function downloadPreview() {
try {
const response = await fetch(route('services.download-preview', props.service.id), {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
showToast(data.message || 'Fehler beim Herunterladen der Vorschau.', 'warning')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'vorschau.proplaylist'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
showToast('Vorschau wurde heruntergeladen.', 'success')
} catch {
showToast('Fehler beim Herunterladen der Vorschau.', 'warning')
}
}
</script> </script>
<template> <template>
@ -619,11 +587,7 @@ async function downloadPreview() {
:service-song-name="(arrangementDialogItem.service_song?.cts_song_name ?? arrangementDialogItem.serviceSong?.cts_song_name) ?? arrangementDialogItem.title ?? ''" :service-song-name="(arrangementDialogItem.service_song?.cts_song_name ?? arrangementDialogItem.serviceSong?.cts_song_name) ?? arrangementDialogItem.title ?? ''"
:service-song-ccli-id="String((arrangementDialogItem.service_song?.cts_ccli_id ?? arrangementDialogItem.serviceSong?.cts_ccli_id) ?? '')" :service-song-ccli-id="String((arrangementDialogItem.service_song?.cts_ccli_id ?? arrangementDialogItem.serviceSong?.cts_ccli_id) ?? '')"
:songs-catalog="songsCatalog" :songs-catalog="songsCatalog"
:worship-leader-name="service.worship_leader_name ?? null"
:service-song-arrangement-id="arrangementDialogItem.service_song?.song_arrangement_id ?? arrangementDialogItem.serviceSong?.song_arrangement_id ?? null"
:default-arrangement-id="(getArrangements(arrangementDialogItem).find((a) => a.is_default))?.id ?? null"
@close="onArrangementDialogClosed" @close="onArrangementDialogClosed"
@unassigned="onArrangementDialogClosed"
/> />
<!-- Sticky bottom action bar --> <!-- Sticky bottom action bar -->
@ -673,17 +637,6 @@ async function downloadPreview() {
</button> </button>
</template> </template>
<template v-else> <template v-else>
<button
data-testid="download-preview-button"
type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700"
@click="downloadPreview"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Vorschau herunterladen
</button>
<button <button
data-testid="service-edit-finalize-button" data-testid="service-edit-finalize-button"
type="button" type="button"

View file

@ -1,6 +1,6 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, router } from '@inertiajs/vue3' import { Head, router } from '@inertiajs/vue3'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
const props = defineProps({ const props = defineProps({
@ -187,38 +187,6 @@ async function downloadService(serviceId) {
} }
} }
async function downloadPreview(serviceId) {
try {
const response = await fetch(route('services.download-preview', serviceId), {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
showToast(data.message || 'Fehler beim Herunterladen der Vorschau.', 'warning')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'vorschau.proplaylist'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
showToast('Vorschau wurde heruntergeladen.', 'success')
} catch {
showToast('Fehler beim Herunterladen der Vorschau.', 'warning')
}
}
function mappingStatusClass(service) { function mappingStatusClass(service) {
if (service.songs_total_count === 0) { if (service.songs_total_count === 0) {
return 'text-red-600' return 'text-red-600'
@ -330,7 +298,7 @@ function stateIconClass(isDone) {
<tbody class="divide-y divide-gray-100 bg-white"> <tbody class="divide-y divide-gray-100 bg-white">
<tr v-for="service in services" :key="service.id" :data-testid="`service-list-row-${service.id}`" class="align-top hover:bg-gray-50/60"> <tr v-for="service in services" :key="service.id" :data-testid="`service-list-row-${service.id}`" class="align-top hover:bg-gray-50/60">
<td class="px-4 py-4"> <td class="px-4 py-4">
<Link :href="route('services.edit', service.id)" class="font-medium text-gray-900 hover:underline cursor-pointer" :title="'CTS Event #' + service.cts_event_id">{{ service.title }}</Link> <div class="font-medium text-gray-900" :title="'CTS Event #' + service.cts_event_id">{{ service.title }}</div>
<div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div> <div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div>
</td> </td>
@ -454,14 +422,6 @@ function stateIconClass(isDone) {
> >
Abschließen Abschließen
</button> </button>
<button
data-testid="download-preview-button"
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700"
@click="downloadPreview(service.id)"
>
Vorschau herunterladen
</button>
<button <button
data-testid="service-list-delete-button" data-testid="service-list-delete-button"
type="button" type="button"

View file

@ -1,14 +1,11 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import MacroPicker from '@/Components/MacroPicker.vue'
import LabelPicker from '@/Components/LabelPicker.vue'
import AgendaSettings from './Settings/AgendaSettings.vue' import AgendaSettings from './Settings/AgendaSettings.vue'
import ExportProFiles from './Settings/ExportProFiles.vue'
import LabelImport from './Settings/LabelImport.vue' import LabelImport from './Settings/LabelImport.vue'
import MacroAssignments from './Settings/MacroAssignments.vue' import MacroAssignments from './Settings/MacroAssignments.vue'
import MacroImport from './Settings/MacroImport.vue' import MacroImport from './Settings/MacroImport.vue'
import { Head, router } from '@inertiajs/vue3' import { Head, router } from '@inertiajs/vue3'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
const props = defineProps({ const props = defineProps({
settings: { type: Object, default: () => ({}) }, settings: { type: Object, default: () => ({}) },
@ -18,16 +15,15 @@ const props = defineProps({
collections: { type: Array, default: () => [] }, collections: { type: Array, default: () => [] },
last_macros_import: { type: Object, default: () => ({}) }, last_macros_import: { type: Object, default: () => ({}) },
last_labels_import: { type: Object, default: () => ({}) }, last_labels_import: { type: Object, default: () => ({}) },
export_pro_files: { type: Object, default: () => ({ prefix: [], postfix: [] }) },
}) })
const submenus = [ const submenus = [
{ key: 'assignments', label: 'Makro-Zuweisungen' }, { key: 'assignments', label: 'Makro-Zuweisungen' },
{ key: 'macros', label: 'Makro' }, { key: 'macros', label: 'Makro-Import' },
{ key: 'labels', label: 'Label' }, { key: 'labels', label: 'Label-Import' },
{ key: 'agenda', label: 'Agenda' }, { key: 'agenda', label: 'Agenda' },
{ key: 'ccli', label: 'CCLI Import' }, { key: 'ccli', label: 'CCLI Import' },
{ key: 'export-files', label: 'Export-Dateien' }, { key: 'namenseinblender', label: 'Namenseinblender' },
] ]
const activeSubmenu = ref('assignments') const activeSubmenu = ref('assignments')
@ -72,22 +68,6 @@ async function updateSetting(key, value) {
// silent fail // silent fail
} }
} }
// Task 5: Namenseinblender macro picker (moved into assignments panel)
const nametagMacroId = ref(props.settings.namenseinblender_macro_id ? Number(props.settings.namenseinblender_macro_id) : null)
watch(nametagMacroId, (value) => {
updateSetting('namenseinblender_macro_id', value === null ? '' : String(value))
})
// Task 6: Song prefix/postfix label defaults
const songPrefixLabelId = ref(props.settings.song_prefix_label_id ? Number(props.settings.song_prefix_label_id) : null)
const songPostfixLabelId = ref(props.settings.song_postfix_label_id ? Number(props.settings.song_postfix_label_id) : null)
watch(songPrefixLabelId, (value) => {
updateSetting('song_prefix_label_id', value === null ? '' : String(value))
})
watch(songPostfixLabelId, (value) => {
updateSetting('song_postfix_label_id', value === null ? '' : String(value))
})
</script> </script>
<template> <template>
@ -145,31 +125,15 @@ watch(songPostfixLabelId, (value) => {
</div> </div>
<!-- Content panel --> <!-- Content panel -->
<div class="flex-1 overflow-y-auto p-6 max-h-[calc(100vh-12rem)]" data-testid="settings-active-panel"> <div class="flex-1 p-6" data-testid="settings-active-panel">
<!-- Makro-Zuweisungen: includes Namenseinblender at top -->
<div v-if="activeSubmenu === 'assignments'" class="space-y-6">
<!-- Task 5: Namenseinblender MacroPicker at top of assignments -->
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="mb-1 text-sm font-semibold text-gray-900">Namenseinblender</h3>
<p class="mb-3 text-xs text-gray-500">
Wähle das ProPresenter-Makro für die Namenseinblendung bei Moderation und Predigt. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert.
</p>
<MacroPicker
v-model="nametagMacroId"
:macros="macros"
:collections="collections"
data-testid="namenseinblender-macro-picker"
/>
</div>
<MacroAssignments <MacroAssignments
v-if="activeSubmenu === 'assignments'"
:assignments="assignments" :assignments="assignments"
:macros="macros" :macros="macros"
:labels="labels" :labels="labels"
:collections="collections" :collections="collections"
@switch-submenu="switchSubmenu" @switch-submenu="switchSubmenu"
/> />
</div>
<MacroImport <MacroImport
v-if="activeSubmenu === 'macros'" v-if="activeSubmenu === 'macros'"
@ -179,40 +143,12 @@ watch(songPostfixLabelId, (value) => {
@switch-submenu="switchSubmenu" @switch-submenu="switchSubmenu"
/> />
<!-- Label panel: import + prefix/postfix defaults -->
<div v-if="activeSubmenu === 'labels'" class="space-y-6">
<LabelImport <LabelImport
v-if="activeSubmenu === 'labels'"
:labels="labels" :labels="labels"
:last_labels_import="last_labels_import" :last_labels_import="last_labels_import"
/> />
<!-- Task 6: Song prefix/postfix label defaults -->
<div class="rounded-lg border border-gray-200 p-4 space-y-4">
<div>
<h3 class="text-sm font-semibold text-gray-900">Standard-Labels für Songs</h3>
<p class="mt-1 text-xs text-gray-500">
Diese Labels werden bei jedem Song-Export automatisch als Prefix (z.&nbsp;B. COPYRIGHT) und Postfix (z.&nbsp;B. BLANK) angehängt.
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Prefix-Label (Standard: COPYRIGHT)</label>
<LabelPicker
v-model="songPrefixLabelId"
:labels="labels"
data-testid="song-prefix-label-picker"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Postfix-Label (Standard: BLANK)</label>
<LabelPicker
v-model="songPostfixLabelId"
:labels="labels"
data-testid="song-postfix-label-picker"
/>
</div>
</div>
</div>
<AgendaSettings <AgendaSettings
v-if="activeSubmenu === 'agenda'" v-if="activeSubmenu === 'agenda'"
:settings="settings" :settings="settings"
@ -288,13 +224,60 @@ watch(songPostfixLabelId, (value) => {
</div> </div>
</div> </div>
<!-- Task 7: Export-Dateien submenu --> <!-- Namenseinblender Macro Settings -->
<ExportProFiles <div v-if="activeSubmenu === 'namenseinblender'" class="space-y-6">
v-if="activeSubmenu === 'export-files'" <div>
:prefix-files="export_pro_files.prefix" <h3 class="text-base font-semibold text-gray-900">Namenseinblender-Makro</h3>
:postfix-files="export_pro_files.postfix" <p class="mt-1 text-sm text-gray-500">
Konfiguriere das ProPresenter-Makro, das für die Namenseinblendung bei Moderation und Predigt verwendet wird. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert.
</p>
</div>
<div class="rounded-lg border border-gray-200 p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Makro-Name</label>
<input
type="text"
:value="settings.namenseinblender_macro_name || ''"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="z.B. Namenseinblender"
data-testid="namenseinblender-macro-name"
@change="updateSetting('namenseinblender_macro_name', $event.target.value)"
/> />
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Makro-UUID</label>
<input
type="text"
:value="settings.namenseinblender_macro_uuid || ''"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono"
placeholder="UUID des Makros"
data-testid="namenseinblender-macro"
@change="updateSetting('namenseinblender_macro_uuid', $event.target.value)"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Collection-Name</label>
<input
type="text"
:value="settings.namenseinblender_macro_collection_name || '--MAIN--'"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="--MAIN--"
@change="updateSetting('namenseinblender_macro_collection_name', $event.target.value)"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Collection-UUID</label>
<input
type="text"
:value="settings.namenseinblender_macro_collection_uuid || ''"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono"
placeholder="UUID der Collection"
@change="updateSetting('namenseinblender_macro_collection_uuid', $event.target.value)"
/>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,226 +0,0 @@
<script setup>
import { ref } from 'vue'
import { route } from 'ziggy-js'
const props = defineProps({
prefixFiles: { type: Array, default: () => [] },
postfixFiles: { type: Array, default: () => [] },
})
const prefixDragActive = ref(false)
const postfixDragActive = ref(false)
const uploading = ref(false)
const error = ref(null)
function getXsrfToken() {
return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '')
}
async function uploadFiles(type, files) {
if (!files || files.length === 0) return
uploading.value = true
error.value = null
const form = new FormData()
form.append('type', type)
for (const file of files) {
form.append('files[]', file)
}
try {
const res = await fetch(route('settings.export-pro-files.store'), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getXsrfToken(),
},
body: form,
})
if (!res.ok) {
const data = await res.json()
error.value = data.message || 'Upload fehlgeschlagen'
return
}
window.location.reload()
} catch {
error.value = 'Netzwerkfehler beim Upload'
} finally {
uploading.value = false
}
}
function handlePrefixChange(event) {
const files = event.target.files
if (!files || files.length === 0) return
event.target.value = ''
uploadFiles('prefix', files)
}
function handlePostfixChange(event) {
const files = event.target.files
if (!files || files.length === 0) return
event.target.value = ''
uploadFiles('postfix', files)
}
function handlePrefixDrop(event) {
prefixDragActive.value = false
uploadFiles('prefix', event.dataTransfer.files)
}
function handlePostfixDrop(event) {
postfixDragActive.value = false
uploadFiles('postfix', event.dataTransfer.files)
}
async function deleteFile(id) {
try {
await fetch(route('settings.export-pro-files.destroy', id), {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getXsrfToken(),
},
})
window.location.reload()
} catch {
// silent fail
}
}
</script>
<template>
<div class="space-y-8">
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
<p class="text-xs text-gray-500">
Diese .pro- und .probundle-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt.
</p>
</div>
<div
v-if="error"
class="rounded-lg bg-red-50 p-3 text-sm text-red-700"
>
{{ error }}
</div>
<!-- Prefix section -->
<div>
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Prefix-Dateien</h4>
<p class="mb-3 text-xs text-gray-400">Werden vor dem Gottesdienst eingefügt.</p>
<label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
:class="prefixDragActive
? 'border-amber-400 bg-amber-50'
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
data-testid="export-prefix-dropzone"
@dragover.prevent
@dragenter.prevent="prefixDragActive = true"
@dragleave.prevent="prefixDragActive = false"
@drop.prevent="handlePrefixDrop"
>
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span class="text-sm text-gray-500">
{{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }}
</span>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
<input
type="file"
class="hidden"
accept=".pro,.probundle"
multiple
:disabled="uploading"
data-testid="export-prefix-file-input"
@change="handlePrefixChange"
/>
</label>
<div v-if="prefixFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100">
<div
v-for="file in prefixFiles"
:key="file.id"
class="flex items-center gap-3 px-3 py-2 text-sm"
>
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span class="flex-1 truncate text-gray-700">{{ file.original_name }}</span>
<span class="text-xs text-gray-400">#{{ file.order + 1 }}</span>
<button
class="ml-2 text-gray-400 hover:text-red-500"
:data-testid="'export-pro-file-delete-' + file.id"
@click="deleteFile(file.id)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<p v-else class="text-sm text-gray-400">Noch keine Prefix-Dateien hochgeladen.</p>
</div>
<!-- Postfix section -->
<div>
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Postfix-Dateien</h4>
<p class="mb-3 text-xs text-gray-400">Werden nach dem Gottesdienst angehängt.</p>
<label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
:class="postfixDragActive
? 'border-amber-400 bg-amber-50'
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
data-testid="export-postfix-dropzone"
@dragover.prevent
@dragenter.prevent="postfixDragActive = true"
@dragleave.prevent="postfixDragActive = false"
@drop.prevent="handlePostfixDrop"
>
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span class="text-sm text-gray-500">
{{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }}
</span>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
<input
type="file"
class="hidden"
accept=".pro,.probundle"
multiple
:disabled="uploading"
data-testid="export-postfix-file-input"
@change="handlePostfixChange"
/>
</label>
<div v-if="postfixFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100">
<div
v-for="file in postfixFiles"
:key="file.id"
class="flex items-center gap-3 px-3 py-2 text-sm"
>
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span class="flex-1 truncate text-gray-700">{{ file.original_name }}</span>
<span class="text-xs text-gray-400">#{{ file.order + 1 }}</span>
<button
class="ml-2 text-gray-400 hover:text-red-500"
:data-testid="'export-pro-file-delete-' + file.id"
@click="deleteFile(file.id)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<p v-else class="text-sm text-gray-400">Noch keine Postfix-Dateien hochgeladen.</p>
</div>
</div>
</template>

View file

@ -10,11 +10,11 @@ const props = defineProps({
const uploading = ref(false) const uploading = ref(false)
const result = ref(null) const result = ref(null)
const error = ref(null) const error = ref(null)
const dragActive = ref(false)
const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name))) const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name)))
async function uploadFile(file) { async function handleFileChange(event) {
const file = event.target.files[0]
if (!file) return if (!file) return
uploading.value = true uploading.value = true
error.value = null error.value = null
@ -38,6 +38,7 @@ async function uploadFile(file) {
return return
} }
result.value = await res.json() result.value = await res.json()
event.target.value = ''
window.location.reload() window.location.reload()
} catch { } catch {
error.value = 'Netzwerkfehler beim Upload' error.value = 'Netzwerkfehler beim Upload'
@ -45,24 +46,11 @@ async function uploadFile(file) {
uploading.value = false uploading.value = false
} }
} }
function handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
event.target.value = ''
uploadFile(file)
}
function handleDrop(event) {
dragActive.value = false
const file = event.dataTransfer.files[0]
uploadFile(file)
}
</script> </script>
<template> <template>
<div> <div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Label</h3> <h3 class="mb-1 text-sm font-semibold text-gray-900">Label-Import</h3>
<p class="mb-1 text-xs text-gray-500"> <p class="mb-1 text-xs text-gray-500">
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>. Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
@ -83,26 +71,14 @@ function handleDrop(event) {
</span> </span>
</p> </p>
<!-- Task 4: Hint about importing both Labels and Groups files -->
<div class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3 text-xs text-blue-800">
<strong>Hinweis:</strong> Importiere sowohl die Datei Labels" als auch die Datei „Groups", damit sowohl die automatischen als auch die manuellen Labels verfügbar sind.
</div>
<div v-if="last_labels_import?.at" class="mb-4 text-xs text-gray-400"> <div v-if="last_labels_import?.at" class="mb-4 text-xs text-gray-400">
Letzter Import: {{ last_labels_import.at }} Letzter Import: {{ last_labels_import.at }}
<span v-if="last_labels_import.filename">({{ last_labels_import.filename }})</span> <span v-if="last_labels_import.filename">({{ last_labels_import.filename }})</span>
</div> </div>
<label <label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors" class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
:class="dragActive
? 'border-amber-400 bg-amber-50'
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
data-testid="labels-upload-area" data-testid="labels-upload-area"
@dragover.prevent
@dragenter.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="handleDrop"
> >
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />

View file

@ -14,7 +14,6 @@ const uploading = ref(false)
const result = ref(null) const result = ref(null)
const error = ref(null) const error = ref(null)
const selectedCollection = ref(null) const selectedCollection = ref(null)
const dragActive = ref(false)
const filteredMacros = computed(() => { const filteredMacros = computed(() => {
if (!selectedCollection.value) return props.macros if (!selectedCollection.value) return props.macros
@ -24,7 +23,8 @@ const filteredMacros = computed(() => {
return props.macros.filter((m) => ids.includes(m.id)) return props.macros.filter((m) => ids.includes(m.id))
}) })
async function uploadFile(file) { async function handleFileChange(event) {
const file = event.target.files[0]
if (!file) return if (!file) return
uploading.value = true uploading.value = true
error.value = null error.value = null
@ -48,6 +48,7 @@ async function uploadFile(file) {
return return
} }
result.value = await res.json() result.value = await res.json()
event.target.value = ''
window.location.reload() window.location.reload()
} catch { } catch {
error.value = 'Netzwerkfehler beim Upload' error.value = 'Netzwerkfehler beim Upload'
@ -55,24 +56,11 @@ async function uploadFile(file) {
uploading.value = false uploading.value = false
} }
} }
function handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
event.target.value = ''
uploadFile(file)
}
function handleDrop(event) {
dragActive.value = false
const file = event.dataTransfer.files[0]
uploadFile(file)
}
</script> </script>
<template> <template>
<div> <div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Makro</h3> <h3 class="mb-1 text-sm font-semibold text-gray-900">Makro-Import</h3>
<p class="mb-1 text-xs text-gray-500"> <p class="mb-1 text-xs text-gray-500">
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>. Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
@ -99,15 +87,8 @@ function handleDrop(event) {
</div> </div>
<label <label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors" class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
:class="dragActive
? 'border-amber-400 bg-amber-50'
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
data-testid="macros-upload-area" data-testid="macros-upload-area"
@dragover.prevent
@dragenter.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="handleDrop"
> >
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />

View file

@ -14,9 +14,6 @@ const props = defineProps({
const editSongId = ref(null) const editSongId = ref(null)
function handleClose() { function handleClose() {
// Don't navigate away if an edit is opening (import & edit flow).
if (editSongId.value !== null) return
router.visit(route('songs.index')) router.visit(route('songs.index'))
} }
@ -25,14 +22,6 @@ function handleImported(songId, mode) {
router.visit(route('songs.index')) router.visit(route('songs.index'))
} }
} }
function handleEditSong(id) {
editSongId.value = id
}
function handleEditClose() {
router.visit(route('songs.index'))
}
</script> </script>
<template> <template>
@ -59,19 +48,18 @@ function handleEditClose() {
<!-- Always-open paste dialog on this page --> <!-- Always-open paste dialog on this page -->
<CcliPasteDialog <CcliPasteDialog
:open="editSongId === null" :open="true"
mode="songdb" mode="songdb"
:prefilled-text="prefilledText" :prefilled-text="prefilledText"
:auto-edit="prefilledText !== null"
@close="handleClose" @close="handleClose"
@imported="handleImported" @imported="handleImported"
@edit-song="handleEditSong" @edit-song="(id) => { editSongId = id }"
/> />
<SongEditModal <SongEditModal
:show="editSongId !== null" :show="editSongId !== null"
:song-id="editSongId" :song-id="editSongId"
@close="handleEditClose" @close="() => router.visit(route('songs.index'))"
/> />
</div> </div>
</div> </div>

View file

@ -4,7 +4,6 @@
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarkletController; use App\Http\Controllers\BookmarkletController;
use App\Http\Controllers\CcliPasteController; use App\Http\Controllers\CcliPasteController;
use App\Http\Controllers\ExportProFileController;
use App\Http\Controllers\LabelImportController; use App\Http\Controllers\LabelImportController;
use App\Http\Controllers\MacroAssignmentController; use App\Http\Controllers\MacroAssignmentController;
use App\Http\Controllers\MacroImportController; use App\Http\Controllers\MacroImportController;
@ -16,7 +15,6 @@
use App\Http\Controllers\SongSectionController; use App\Http\Controllers\SongSectionController;
use App\Http\Controllers\SyncController; use App\Http\Controllers\SyncController;
use App\Http\Controllers\TranslationController; use App\Http\Controllers\TranslationController;
use App\Models\Service;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
@ -43,11 +41,7 @@
); );
Auth::login($user); Auth::login($user);
$service = Service::nextUpcoming()->first(); return redirect()->route('dashboard');
return $service
? redirect()->route('services.edit', $service->id)
: redirect()->route('services.index');
})->name('dev-login'); })->name('dev-login');
}); });
} }
@ -60,13 +54,7 @@
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/', function () { Route::get('/', function () {
$service = Service::nextUpcoming()->first(); return redirect()->route('dashboard');
if ($service) {
return redirect()->route('services.edit', $service->id);
}
return redirect()->route('services.index');
}); });
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
@ -78,7 +66,6 @@
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen'); Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy'); Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy');
Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download'); Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download');
Route::get('/services/{service}/download-preview', [ServiceController::class, 'downloadPreview'])->name('services.download-preview');
Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle'); Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle');
Route::get('/services/{service}/agenda-items/{agendaItem}/download', [ServiceController::class, 'downloadAgendaItem'])->name('services.agenda-item.download'); Route::get('/services/{service}/agenda-items/{agendaItem}/download', [ServiceController::class, 'downloadAgendaItem'])->name('services.agenda-item.download');
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit'); Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
@ -128,8 +115,6 @@
| Makro- und Label-Import (ProPresenter) | Makro- und Label-Import (ProPresenter)
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
Route::post('/settings/export-pro-files', [ExportProFileController::class, 'store'])->name('settings.export-pro-files.store');
Route::delete('/settings/export-pro-files/{exportProFile}', [ExportProFileController::class, 'destroy'])->name('settings.export-pro-files.destroy');
Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import'); Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import');
Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import'); Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import');

View file

@ -44,7 +44,7 @@ function ccliFixture(string $name): string
expect($arrangement)->not->toBeNull() expect($arrangement)->not->toBeNull()
->and($arrangement->is_default)->toBeTrue() ->and($arrangement->is_default)->toBeTrue()
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7) ->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5)
->and(SongSlide::count())->toBe(5); ->and(SongSlide::count())->toBe(5);
}); });
@ -93,7 +93,7 @@ function ccliFixture(string $name): string
->and($song->title)->toBe('Heilig ist der Herr') ->and($song->title)->toBe('Heilig ist der Herr')
->and($song->author)->toBe('Albert Frey') ->and($song->author)->toBe('Albert Frey')
->and($arrangement)->not->toBeNull() ->and($arrangement)->not->toBeNull()
->and($arrangement->arrangementSections)->toHaveCount(4) ->and($arrangement->arrangementSections)->toHaveCount(2)
->and(SongSlide::count())->toBe(7); ->and(SongSlide::count())->toBe(7);
}); });

View file

@ -1,278 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\User;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
use ZipArchive;
final class ExportProFileInjectionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Storage::fake('public');
}
private function createSongWithContent(string $title): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$label = Label::firstOrCreate(
['name' => 'Verse - '.$title],
['color' => '#2196F3'],
);
$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->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
private function buildProbundleZip(string $proContent, array $mediaFiles = []): string
{
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
$zip = new ZipArchive();
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('song.pro', $proContent);
foreach ($mediaFiles as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
return $tmpPath;
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
public function test_probundle_prefix_wird_korrekt_injiziert(): void
{
$user = User::factory()->create();
$service = Service::factory()->create([
'title' => 'Bundle Test Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Test Song');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Test Song',
'order' => 1,
]);
$proContent = 'dummy-pro-content';
$mediaContent = 'dummy-media-bytes';
$bundlePath = $this->buildProbundleZip($proContent, ['background.jpg' => $mediaContent]);
$storedPath = 'export-pro-files/intro.probundle';
Storage::disk('local')->put($storedPath, file_get_contents($bundlePath));
unlink($bundlePath);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'intro.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
$this->assertContains('intro', $entryNames, 'Expected prefix presentation entry named "intro"');
$songPos = array_search('Test Song', $entryNames, true);
$prefixPos = array_search('intro', $entryNames, true);
$this->assertNotFalse($prefixPos, 'Prefix entry not found');
$this->assertNotFalse($songPos, 'Song entry not found');
$this->assertLessThan($songPos, $prefixPos, 'Prefix should appear before song');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('PREFIX_1_intro.pro', $embeddedFiles, 'Inner .pro should be embedded as PREFIX_1_intro.pro');
$this->assertSame($proContent, $embeddedFiles['PREFIX_1_intro.pro']);
$this->assertArrayHasKey('background.jpg', $embeddedFiles, 'Media file from .probundle should be embedded');
$this->assertSame($mediaContent, $embeddedFiles['background.jpg']);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_postfix_wird_nach_songs_injiziert(): void
{
$service = Service::factory()->create([
'title' => 'Postfix Bundle Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Worship Song');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Worship Song',
'order' => 1,
]);
$bundlePath = $this->buildProbundleZip('outro-pro-content', ['outro-media.jpg' => 'outro-media-bytes']);
$storedPath = 'export-pro-files/outro.probundle';
Storage::disk('local')->put($storedPath, file_get_contents($bundlePath));
unlink($bundlePath);
ExportProFile::create([
'type' => 'postfix',
'original_name' => 'outro.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
$songPos = array_search('Worship Song', $entryNames, true);
$postfixPos = array_search('outro', $entryNames, true);
$this->assertNotFalse($postfixPos, 'Postfix entry not found');
$this->assertNotFalse($songPos, 'Song entry not found');
$this->assertLessThan($postfixPos, $songPos, 'Song should appear before postfix');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('POSTFIX_1_outro.pro', $embeddedFiles);
$this->assertSame('outro-pro-content', $embeddedFiles['POSTFIX_1_outro.pro']);
$this->assertArrayHasKey('outro-media.jpg', $embeddedFiles);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_ohne_pro_eintrag_wird_uebersprungen(): void
{
$service = Service::factory()->create([
'title' => 'Skip Bundle Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Einziger Song');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Einziger Song',
'order' => 1,
]);
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
$zip = new ZipArchive();
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('media.jpg', 'media-only');
$zip->close();
$storedPath = 'export-pro-files/broken.probundle';
Storage::disk('local')->put($storedPath, file_get_contents($tmpPath));
unlink($tmpPath);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'broken.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries());
$this->assertNotContains('broken', $entryNames);
$this->assertContains('Einziger Song', $entryNames);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_plain_pro_prefix_weiterhin_korrekt(): void
{
$service = Service::factory()->create([
'title' => 'Plain Pro Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Lied',
'order' => 1,
]);
$storedPath = 'export-pro-files/welcome.pro';
Storage::disk('local')->put($storedPath, 'welcome-pro-bytes');
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'welcome.pro',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
$this->assertContains('welcome', $entryNames);
$prefixPos = array_search('welcome', $entryNames, true);
$songPos = array_search('Lied', $entryNames, true);
$this->assertLessThan($songPos, $prefixPos, 'Plain .pro prefix should appear before song');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('PREFIX_1_welcome.pro', $embeddedFiles);
$this->assertSame('welcome-pro-bytes', $embeddedFiles['PREFIX_1_welcome.pro']);
$this->cleanupTempDir($result['temp_dir']);
}
}

View file

@ -1,102 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ExportProFileUploadTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
public function test_pro_datei_wird_als_prefix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('intro.pro', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'prefix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'intro.pro');
}
public function test_probundle_datei_wird_als_prefix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('intro.probundle', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'prefix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'intro.probundle');
}
public function test_pro_datei_wird_als_postfix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('outro.pro', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'postfix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'outro.pro');
}
public function test_probundle_datei_wird_als_postfix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('outro.probundle', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'postfix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'outro.probundle');
}
public function test_unerlaubte_dateiendung_wird_abgelehnt(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
$response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [
'type' => 'prefix',
'files' => [$file],
]);
$response->assertStatus(422);
}
public function test_unerlaubte_dateiendung_postfix_wird_abgelehnt(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
$response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [
'type' => 'postfix',
'files' => [$file],
]);
$response->assertStatus(422);
}
}

View file

@ -1,8 +1,6 @@
<?php <?php
use App\Models\Service;
use App\Models\User; use App\Models\User;
use Carbon\Carbon;
test('home route redirects unauthenticated users to login', function () { test('home route redirects unauthenticated users to login', function () {
$response = $this->get('/'); $response = $this->get('/');
@ -10,23 +8,10 @@
$response->assertRedirect('/login'); $response->assertRedirect('/login');
}); });
test('home route redirects authenticated users to next upcoming service edit page', function () { test('home route redirects authenticated users to dashboard', function () {
Carbon::setTestNow('2026-06-01 10:00:00');
$user = User::factory()->create();
$service = Service::factory()->create(['date' => now()->addWeek()]);
$response = $this->actingAs($user)->get('/');
$response->assertRedirect(route('services.edit', $service->id));
});
test('home route redirects authenticated users to services index when no upcoming service exists', function () {
Carbon::setTestNow('2026-06-01 10:00:00');
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this->actingAs($user)->get('/'); $response = $this->actingAs($user)->get('/');
$response->assertRedirect(route('services.index')); $response->assertRedirect(route('dashboard'));
}); });

View file

@ -25,7 +25,7 @@ protected function setUp(): void
Storage::fake('public'); Storage::fake('public');
} }
public function test_non_song_agenda_item_without_slides_becomes_headline(): void public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void
{ {
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
@ -47,23 +47,19 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi
$result = app(PlaylistExportService::class)->generatePlaylist($service); $result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']); $playlist = ProPlaylistReader::read($result['path']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
$fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro');
// Content-less item before any real content → HEADER playlist item (no embedded .pro) $this->assertCount(1, $embeddedProFiles);
$this->assertCount(0, $playlist->getEmbeddedProFiles()); $this->assertNotNull($fallbackSong);
$this->assertSame($slideCountBefore, Slide::count()); $this->assertSame($slideCountBefore, Slide::count());
// Playlist has a header entry named 'Begrüßung' $slides = $this->allParserSlides($fallbackSong);
$headerEntry = null; $this->assertCount(1, $slides);
foreach ($playlist->getEntries() as $entry) { $this->assertTrue($slides[0]->hasBackgroundMedia());
if ($entry->isHeader() && $entry->getName() === 'Begrüßung') { $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
$headerEntry = $entry; $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
break; $this->assertSame('keyvisual-image', $playlist->getEmbeddedMediaFiles()['KEY_VISUAL.jpg']);
}
}
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung');
// No KEY_VISUAL.jpg embedded (header has no background media)
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
} }
@ -153,7 +149,7 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
} }
public function test_empty_non_song_item_before_content_becomes_headline(): void public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void
{ {
$service = Service::factory()->create([ $service = Service::factory()->create([
'title' => 'Ohne Keyvisual', 'title' => 'Ohne Keyvisual',
@ -185,25 +181,10 @@ public function test_empty_non_song_item_before_content_becomes_headline(): void
$result = app(PlaylistExportService::class)->generatePlaylist($service); $result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']); $playlist = ProPlaylistReader::read($result['path']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
// Content-less item before real content → HEADER playlist item (no embedded .pro for headline) $this->assertCount(1, $playlist->getEmbeddedProFiles());
// Only the song .pro is embedded
$this->assertCount(1, $embeddedProFiles);
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro')); $this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
$this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro'));
// Playlist has a header entry named 'Begrüßung ohne Folien'
$headerEntry = null;
foreach ($playlist->getEntries() as $entry) {
if ($entry->isHeader() && $entry->getName() === 'Begrüßung ohne Folien') {
$headerEntry = $entry;
break;
}
}
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung ohne Folien');
// No KEY_VISUAL.jpg embedded (no keyvisual configured)
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
} }

View file

@ -138,173 +138,3 @@
expect($name)->toBeNull(); expect($name)->toBeNull();
}); });
test('worship leader resolves from lobpreis role in responsible persons', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
['service' => '[Moderation]', 'accepted' => true, 'person' => ['title' => 'Moderator Max']],
],
],
'sort_order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis',
'is_before_event' => false,
'responsible' => [
'text' => '[Lobpreis]',
'persons' => [
['service' => '[Lobpreis]', 'accepted' => true, 'person' => ['title' => 'Lea Leiter']],
],
],
'sort_order' => 2,
]);
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
expect($name)->toBe('Lea Leiter');
});
test('worship leader returns null without a lobpreis agenda item', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'responsible' => [['name' => 'Moderator Max']],
'sort_order' => 1,
]);
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
expect($name)->toBeNull();
});
test('name extracted from real cts persons shape via person.title', function () {
$service = Service::factory()->create(['moderator_name' => null]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => [
'title' => 'Kornelius Weiß',
'domainAttributes' => ['firstName' => 'Kornelius', 'lastName' => 'Weiß'],
],
],
],
],
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Kornelius Weiß');
});
test('name falls back to domainAttributes when person.title is absent', function () {
$service = Service::factory()->create(['moderator_name' => null]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => [
'domainAttributes' => ['firstName' => 'Anna', 'lastName' => 'Müller'],
],
],
],
],
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Anna Müller');
});
test('worship leader resolves from persons service role lobpreis in real cts shape', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => ['title' => 'Moderator Max'],
],
],
],
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis',
'is_before_event' => false,
'sort_order' => 2,
'responsible' => [
'text' => '[Lobpreis]',
'persons' => [
[
'service' => '[Lobpreis]',
'accepted' => true,
'person' => ['title' => 'Kornelius Weiß'],
],
],
],
]);
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
expect($name)->toBe('Kornelius Weiß');
});
test('worship leader returns null when no person has lobpreis role in real cts shape', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => ['title' => 'Moderator Max'],
],
],
],
]);
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
expect($name)->toBeNull();
});

View file

@ -1,11 +1,7 @@
<?php <?php
use App\Models\Macro;
use App\Models\Setting; use App\Models\Setting;
use App\Services\NameTagSlideBuilder; use App\Services\NameTagSlideBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('build returns null when namenseinblender macro is not configured', function () { test('build returns null when namenseinblender macro is not configured', function () {
expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull(); expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull();
@ -41,26 +37,3 @@
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration") expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration")
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt"); ->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt");
}); });
test('build returns slide with macro when macro_id points to existing macro', function () {
$macro = Macro::factory()->create([
'name' => 'Namenseinblender',
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
]);
Setting::set('namenseinblender_macro_id', (string) $macro->id);
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
expect($slide)->toHaveKey('text', "Lea Leiter\nModeration")
->and($slide)->toHaveKey('macro')
->and($slide['macro']['name'])->toBe('Namenseinblender')
->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
});
test('build returns slide without macro when macro_id points to missing macro', function () {
Setting::set('namenseinblender_macro_id', '99999');
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
expect($slide)->toBe(['text' => "Lea Leiter\nModeration"]);
});

View file

@ -1,6 +1,5 @@
<?php <?php
use App\Models\Service;
use App\Models\User; use App\Models\User;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser; use Laravel\Socialite\Two\User as SocialiteUser;
@ -51,9 +50,7 @@
}); });
it('creates a new user from OAuth callback', function () { it('creates a new user from OAuth callback', function () {
$service = Service::factory()->create(['date' => now()->addDays(7)]); $socialiteUser = new SocialiteUser;
$socialiteUser = new SocialiteUser();
$socialiteUser->map([ $socialiteUser->map([
'id' => '42', 'id' => '42',
'name' => 'Max Mustermann', 'name' => 'Max Mustermann',
@ -83,7 +80,7 @@
$response = $this->get('/auth/churchtools/callback'); $response = $this->get('/auth/churchtools/callback');
$response->assertRedirect(route('services.edit', $service->id)); $response->assertRedirect(route('dashboard'));
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'email' => 'max@example.com', 'email' => 'max@example.com',
@ -101,15 +98,13 @@
}); });
it('updates existing user on OAuth callback', function () { it('updates existing user on OAuth callback', function () {
$service = Service::factory()->create(['date' => now()->addDays(7)]);
$existingUser = User::factory()->create([ $existingUser = User::factory()->create([
'email' => 'max@example.com', 'email' => 'max@example.com',
'name' => 'Old Name', 'name' => 'Old Name',
'churchtools_id' => 42, 'churchtools_id' => 42,
]); ]);
$socialiteUser = new SocialiteUser(); $socialiteUser = new SocialiteUser;
$socialiteUser->map([ $socialiteUser->map([
'id' => '42', 'id' => '42',
'name' => 'Max Mustermann', 'name' => 'Max Mustermann',
@ -139,7 +134,7 @@
$response = $this->get('/auth/churchtools/callback'); $response = $this->get('/auth/churchtools/callback');
$response->assertRedirect(route('services.edit', $service->id)); $response->assertRedirect(route('dashboard'));
$existingUser->refresh(); $existingUser->refresh();
expect($existingUser->name)->toBe('Max Mustermann'); expect($existingUser->name)->toBe('Max Mustermann');

View file

@ -78,7 +78,8 @@ private function createSlide(array $attributes): Slide
private function createTestableExportService(): PlaylistExportService private function createTestableExportService(): PlaylistExportService
{ {
return new class () extends PlaylistExportService { return new class extends PlaylistExportService
{
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{ {
file_put_contents($path, 'mock-pro-file:'.$name); file_put_contents($path, 'mock-pro-file:'.$name);
@ -374,7 +375,7 @@ public function test_agenda_export_informationen_am_anfang_als_fallback(): void
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
} }
public function test_agenda_export_content_less_items_werden_als_headline_oder_uebersprungen(): void public function test_agenda_export_ueberspringt_items_ohne_slides_oder_songs(): void
{ {
$service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Skip Service']); $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Skip Service']);
@ -418,11 +419,8 @@ public function test_agenda_export_content_less_items_werden_als_headline_oder_u
$this->assertEquals(0, $result['skipped']); $this->assertEquals(0, $result['skipped']);
$playlistContent = file_get_contents($result['path']); $playlistContent = file_get_contents($result['path']);
// Song always appears
$this->assertStringContainsString('Einziger Song', $playlistContent); $this->assertStringContainsString('Einziger Song', $playlistContent);
// Begrüßung (before real content) → HEADLINE → appears in playlist $this->assertStringNotContainsString('Begrüßung', $playlistContent);
$this->assertStringContainsString('Begrüßung', $playlistContent);
// Gebet (first content-less item after real content, no keyvisual) → omitted
$this->assertStringNotContainsString('Gebet', $playlistContent); $this->assertStringNotContainsString('Gebet', $playlistContent);
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);

View file

@ -298,59 +298,6 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
} }
} }
public function test_export_first_slide_macro_nur_auf_erster_folie_des_ganzen_songs(): void
{
$service = Service::factory()->create();
// Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Erste Folie Macro');
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'first_slide',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
// Exactly 3 slides: Verse-0, Verse-1, Chorus-0
$this->assertCount(3, $slides);
// Only the very first slide of the whole song gets the macro
$this->assertTrue($slides[0]->hasMacro(), 'Erste Folie (global index 0) muss Macro haben');
$this->assertSame('Erste Folie Macro', $slides[0]->getMacroName());
// Chorus slide 0 is the first slide of its section but NOT the first of the song → no macro
$this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein first_slide-Macro haben');
$this->assertFalse($slides[2]->hasMacro(), 'Chorus-Folie 0 darf kein first_slide-Macro haben (nicht erste Folie des Songs)');
}
public function test_export_last_slide_macro_nur_auf_letzter_folie_des_ganzen_songs(): void
{
$service = Service::factory()->create();
// Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total; last = Chorus-0
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Letzte Folie Macro');
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'last_slide',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
$this->assertCount(3, $slides);
// Only the very last slide of the whole song gets the macro
$this->assertFalse($slides[0]->hasMacro(), 'Verse-Folie 0 darf kein last_slide-Macro haben');
$this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein last_slide-Macro haben (letzte Folie der Sektion, aber nicht des Songs)');
$this->assertTrue($slides[2]->hasMacro(), 'Letzte Folie des Songs (global index 2) muss Macro haben');
$this->assertSame('Letzte Folie Macro', $slides[2]->getMacroName());
}
public function test_export_ohne_background_enthaelt_keine_background_actions(): void public function test_export_ohne_background_enthaelt_keine_background_actions(): void
{ {
Storage::fake('public'); Storage::fake('public');

View file

@ -34,7 +34,7 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
$song = Song::where('title', 'Test')->first(); $song = Song::where('title', 'Test')->first();
$this->assertNotNull($song); $this->assertNotNull($song);
$this->assertSame(6, \App\Models\Label::count()); $this->assertSame(4, \App\Models\Label::count());
$this->assertSame(5, \App\Models\SongSlide::count()); $this->assertSame(5, \App\Models\SongSlide::count());
$this->assertSame(2, $song->arrangements()->count()); $this->assertSame(2, $song->arrangements()->count());
@ -127,6 +127,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void
$this->assertNotNull($normalArrangement); $this->assertNotNull($normalArrangement);
$this->assertTrue($normalArrangement->is_default); $this->assertTrue($normalArrangement->is_default);
$this->assertSame(7, $normalArrangement->arrangementLabels()->count()); $this->assertSame(5, $normalArrangement->arrangementLabels()->count());
} }
} }

View file

@ -45,8 +45,5 @@
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Destruktive Migration'); $this->expectExceptionMessage('Destruktive Migration');
// The guarded migration (create_song_sections_and_rescope_slides) is 3rd from the top Artisan::call('migrate:rollback', ['--step' => 1]);
// of the migration stack (2 newer migrations were added after it).
// Rolling back 3 steps reaches the destructive guard and triggers the RuntimeException.
Artisan::call('migrate:rollback', ['--step' => 3]);
}); });

View file

@ -1,35 +1,6 @@
#!/bin/bash #!/bin/bash
set -euo pipefail
cd "$(dirname "$0")" git fetch && git pull
# docker compose v2 bevorzugen, Fallback auf v1 docker-compose build
if docker compose version >/dev/null 2>&1; then docker-compose up -d
DC="docker compose"
else
DC="docker-compose"
fi
echo "[deploy] Hole neuesten Stand von origin/master ..."
git fetch --all --prune
# Harter Reset: verhindert pull-Konflikte. Sicher, da public/build, storage/
# und database/ gitignored sind und nicht angefasst werden.
git reset --hard origin/master
# propresenter/parser ist dev-master und in composer.lock auf einen festen
# Commit gepinnt. Ohne Lock-Aenderung bleibt die Docker-Composer-Layer gecacht
# und ein neuer Parser-Stand wuerde NIE deployt. Daher Lock hier aktiv heben.
echo "[deploy] Aktualisiere dev-master Abhaengigkeit (propresenter/parser) ..."
docker run --rm -v "$PWD":/app -w /app composer:2 \
update propresenter/parser --no-scripts --no-interaction
echo "[deploy] Baue Image (inkl. aktualisierter Basis-Images) ..."
$DC build --pull
echo "[deploy] Starte Container neu ..."
$DC up -d --force-recreate
echo "[deploy] Raeume alte Images auf ..."
docker image prune -f
echo "[deploy] Fertig."