Compare commits

..

23 commits

Author SHA1 Message Date
Thorsten Bus ff3484466b fix(songs): resolve seven song/service editing bugs
- CCLI import: group lyrics into 2-line slides (no blank line per line)
- Add-section: searchable label combobox with create-new option
- Service edit: show current global key-visual/background default live
- Assign dialog: prefill+open search, SongSelect link by CCLI nr/name
- "Auf SongSelect suchen" now also opens the CCLI import dialog
- SongDB: mark empty songs "Ohne Inhalt", default-on content filter
- Translation paste: strip section-mark lines so line mapping holds
2026-05-31 21:39:44 +02:00
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00
Thorsten Bus e95abbc1e6 feat(export): sermon sequence + moderator injection 2026-05-31 06:30:25 +02:00
Thorsten Bus e2d6d813de test(e2e): nametag name override fields + namenseinblender settings 2026-05-31 05:03:55 +02:00
Thorsten Bus 4606bb26d6 fix(e2e): correct auth path + strict selector for Namenseinblender 2026-05-31 05:01:38 +02:00
Thorsten Bus 078811e959 fix(ui): remove unused usePage import in ServiceImagePanel 2026-05-31 04:42:35 +02:00
Thorsten Bus ec275ec026 docs: keyvisual/background/nametag features 2026-05-31 04:35:09 +02:00
Thorsten Bus c544f1db60 test: full playlist export assertions 2026-05-31 04:32:47 +02:00
Thorsten Bus 45221ced32 test(e2e): image upload + detail page 2026-05-31 04:20:54 +02:00
Thorsten Bus b36ed6e221 feat(ui): name overrides + namenseinblender setting 2026-05-31 03:41:24 +02:00
Thorsten Bus f948b5665c feat(ui): keyvisual/background panels 2026-05-31 03:38:22 +02:00
Thorsten Bus edceebb2f8 feat(service): finalize snapshot + sync protection 2026-05-31 02:33:32 +02:00
Thorsten Bus 929bda2018 feat(export): sermon sequence + moderator injection 2026-05-31 01:58:08 +02:00
Thorsten Bus bb877d16c6 feat(export): nametag slide builder 2026-05-31 00:48:46 +02:00
Thorsten Bus a19c967594 feat(export): keyvisual fallback slides 2026-05-31 00:43:59 +02:00
Thorsten Bus 196657b52b feat(export): background layer on song/sermon slides 2026-05-31 00:37:23 +02:00
Thorsten Bus d2193bb3b2 feat(service): moderator/preacher name resolution 2026-05-31 00:20:33 +02:00
Thorsten Bus b31f21959f feat(service): keyvisual/background upload + scope choice 2026-05-31 00:15:08 +02:00
Thorsten Bus 1ce30b76e3 feat(service): lazy image resolver 2026-05-31 00:08:50 +02:00
Thorsten Bus 38e79553eb feat(settings): namenseinblender macro + default image settings 2026-05-31 00:04:49 +02:00
Thorsten Bus 7de25b7423 docs: add T3 evidence and learnings
Record the verification output and task notes for the service image column work.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:01:51 +02:00
Thorsten Bus 6061e4c4dd feat(service): add image columns and overrides
Enable storage-backed key visuals and background images plus service-specific moderator and preacher name overrides.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:01:24 +02:00
Thorsten Bus 73a523d0e1 feat(images): cover-fit conversion mode 2026-05-30 23:56:05 +02:00
102 changed files with 5756 additions and 630 deletions

View file

@ -0,0 +1,19 @@
Task T10 evidence: configured Namenseinblender macro
RED:
- `ddev exec php artisan test tests/Feature/NameTagSlideBuilderTest.php`
- Failed with `Target class [App\Services\NameTagSlideBuilder] does not exist.` before implementation.
GREEN:
- `ddev exec php artisan test tests/Feature/NameTagSlideBuilderTest.php`
- Result: 3 passed (4 assertions).
Full verification:
- `ddev exec php artisan test`
- Result: 544 passed (2703 assertions).
- `ddev exec ./vendor/bin/pint`
- Result: PASS, 213 files.
Configured macro contract verified by test:
- text: `Anna Müller\nModeration`
- macro keys: `name`, `uuid`, `collectionName`, `collectionUuid`

View file

@ -0,0 +1,13 @@
Task T10 evidence: no Namenseinblender macro configured
Test coverage:
- `build returns null when namenseinblender macro is not configured`
- Verifies `NameTagSlideBuilder::build('Max Mustermann', 'Moderation')` returns `null` when `namenseinblender_macro_name` is missing.
Targeted verification:
- `ddev exec php artisan test tests/Feature/NameTagSlideBuilderTest.php`
- Result: 3 passed (4 assertions).
Full verification:
- `ddev exec php artisan test`
- Result: 544 passed (2703 assertions).

View file

@ -0,0 +1,7 @@
PASS Tests\Feature\FileConversionServiceTest
✓ contain conversion keeps black bars and fullCover false 0.89s
Tests: 1 passed (12 assertions)
Duration: 1.06s

View file

@ -0,0 +1,16 @@
PASS Tests\Feature\FileConversionServiceTest
✓ cover conversion fills 1920x1080 without black bars 0.71s
✓ contain conversion keeps black bars and fullCover false 0.16s
✓ cover conversion upscales small sources with German quality warning 0.16s
PASS Tests\Feature\FileConversionTest
✓ convert image creates 1920x1080 jpg with black bars and thumbnail 0.13s
✓ small image is upscaled with at most two black bars 0.14s
✓ exact 16:9 image has no black bars 0.26s
✓ small 16:9 image is upscaled without black bars 0.17s
✓ portrait image gets pillarbox bars on left and right 0.21s
Tests: 8 passed (67 assertions)
Duration: 2.11s

View file

@ -0,0 +1,6 @@
Task T3 evidence
- `ddev exec php artisan test --filter=ServiceImageColumns` ✅
- Result: 4 passed, 11 assertions
- Migration: `2026_05_10_115900_add_image_fields_to_services_table.php`
- `ddev exec php artisan test` ✅ 510 passed

View file

@ -0,0 +1,16 @@
Task T5 fallthrough evidence
Covered fallback cases in tests/Feature/ServiceImageResolverTest.php:
- Service key visual exists in public storage -> returns service filename.
- Service key visual is null and global key visual exists in public storage -> returns global filename.
- Service key visual and global key visual are null -> returns null.
- Service key visual references a missing file, while global key visual exists -> skips missing service file and returns global filename.
- Background resolver covers the same three branches: service file, global fallback, null.
The implementation uses Storage::disk('public')->exists(...) before returning any referenced filename, so nonexistent paths fall through instead of being returned.
Verification:
- Initial RED: ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php failed because App\Services\ServiceImageResolver did not exist.
- GREEN: ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php passed after implementation.
- Full suite: ddev exec php artisan test passed with 519 tests / 2627 assertions.

View file

@ -0,0 +1,15 @@
Task T5 resolution order evidence
ServiceImageResolver implements lazy raw-filename resolution for service imagery:
- keyVisualFor(Service $service): checks $service->key_visual_filename first.
- backgroundFor(Service $service): checks $service->background_filename first.
- If the per-service filename is null or missing on Storage::disk('public'), the resolver checks the matching global Setting key.
- Global keys: current_key_visual, current_background.
- Return contract: raw relative filename such as slides/kv.jpg, never /storage/... URL.
- Pure behavior: no model updates, no Setting writes, no Storage writes.
Verification:
- ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php: PASS (5 tests)
- ddev exec php artisan test: PASS (519 tests, 2627 assertions)
- ddev exec ./vendor/bin/pint app/Services/ServiceImageResolver.php tests/Feature/ServiceImageResolverTest.php: PASS

View file

@ -0,0 +1,16 @@
Task T7 moderator evidence
Implemented App\Services\NameTagResolver::moderatorFor(Service $service).
Covered behavior:
- Non-empty services.moderator_name wins and is trimmed.
- Without override, first visible agenda item (is_before_event=false) ordered by sort_order then id is used.
- Multiple responsible names are joined with comma-space: "Anna Müller, Tom Klein".
- No override and no visible agenda item returns null.
Verification:
- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist.
- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed.
- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions).
- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php.
- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php.

View file

@ -0,0 +1,28 @@
Task T7 preacher evidence
Implemented App\Services\NameTagResolver::preacherFor(Service $service).
Covered behavior:
- Non-empty services.preacher_name_override wins and is trimmed.
- Without override, services.preacher_name (CTS Predigt role) is returned.
- Without both fields, first visible non-song sermon agenda item is resolved by agenda_sermon_matching patterns via AgendaMatcherService.
- Multiple responsible entries are supported with comma-space joining; empty/missing names return null.
- No override, no CTS preacher name, and no sermon responsible returns null.
Responsible JSON findings:
- ServiceAgendaItem.responsible is cast to array.
- Existing sync test stores associative object shape: {"name":"Max Mustermann"}.
- New tests cover the expected multiple-person list shape: [{"name":"Anna Müller"},{"name":"Tom Klein"}].
- Resolver also supports string entries and firstName/lastName or first_name/last_name fallbacks.
Sermon detection used:
- Prefer configured Setting key agenda_sermon_matching, split by comma, matched with AgendaMatcherService::matchesAny().
- Only visible non-song agenda items are considered (is_before_event=false, service_song_id IS NULL).
- If no setting exists, title/type substring fallback accepts predigt or sermon.
Verification:
- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist.
- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed.
- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions).
- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php.
- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php.

View file

@ -0,0 +1,15 @@
Task T8 evidence — no background / excluded slide types
Verified by `Tests\Feature\ProFileExportTest`:
- `test_export_ohne_background_enthaelt_keine_background_actions`
- Service without resolved background generates successfully
- exported song slides contain zero BACKGROUND media actions
- `test_information_und_moderation_exports_erhalten_keinen_background`
- information bundle gets no BACKGROUND media
- moderation bundle gets no BACKGROUND media
- `test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen`
- final playlist keeps information slides without BACKGROUND media
Verification commands:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php` → 13 passed, 76 assertions
- `ddev exec php artisan test` → 537 passed, 2683 assertions

View file

@ -0,0 +1,20 @@
Task T8 evidence — song/sermon background layer
RED:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
- Failed as expected before implementation:
- song slides had no BACKGROUND media action
- slides table had no `cover_mode` column for full-cover detection
GREEN:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
- 13 passed, 76 assertions
- Verified:
- every song text slide gets BACKGROUND media when ServiceImageResolver resolves a background
- sermon image slides get BACKGROUND media when not full-cover
- full-cover sermon image slides (`cover_mode=true`) skip BACKGROUND media
- final .proplaylist export preserves the same sermon/full-cover behavior
Full suite:
- `ddev exec ./vendor/bin/pint ... && ddev exec php artisan test`
- 537 passed, 2683 assertions

View file

@ -0,0 +1,15 @@
Task T9 keyvisual fallback evidence
Scenario: Service has key_visual_filename and a visible non-song agenda item with no uploaded/special slides.
Result:
- PlaylistExportService creates an embedded .pro for the agenda item title.
- The generated .pro contains exactly one image-only slide with background media.
- Background media URL is Storage::disk('public')->path($keyvisual).
- Slide rows are not created; Slide::count() remains unchanged.
Verification:
- RED before implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` failed with `Keine Songs mit Inhalt zum Exportieren gefunden.` for empty non-song agenda item.
- GREEN after implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` → 4 passed, 16 assertions.
- Full suite: `ddev exec php artisan test` → 541 passed, 2699 assertions.
- Pint: `ddev exec ./vendor/bin/pint app/Services/PlaylistExportService.php tests/Feature/KeyVisualFallbackTest.php` → PASS, 2 files.

View file

@ -0,0 +1,16 @@
Task T9 no-keyvisual evidence
Scenario: Service has no keyvisual and a visible non-song agenda item with no uploaded/special slides.
Result:
- Empty non-song agenda item adds no playlist entry and no .pro file.
- Song agenda items still export through the normal song .pro path.
- Uploaded agenda slides still export normally; no keyvisual slide is prepended.
- Song agenda items never receive keyvisual fallback slides.
Verification:
- `tests/Feature/KeyVisualFallbackTest.php` covers:
- no keyvisual → no empty-item .pro entry,
- song item → only song .pro,
- uploaded slides → exactly the uploaded slide presentation and no fallback background.
- Full suite after implementation: 541 passed, 2699 assertions.

View file

@ -0,0 +1,110 @@
# Learnings — keyvisual-background-nametag
> Append with `## [TIMESTAMP] Task: {task-id}\n{content}`. NEVER overwrite.
## [2026-05-30] Task: T1 — Parser background-layer
- Parser repo is at `/Users/thorsten/AI/propresenter` (NOT `propresenter-work/php` as plan stated).
- composer.json uses VCS remote `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (NOT a path repo).
- For development: copy changed parser files into `vendor/propresenter/parser/src/` in the app. All future parser changes must also be synced: `cp /Users/thorsten/AI/propresenter/src/CHANGED.php /Users/thorsten/AI/pp-planer/vendor/propresenter/parser/src/CHANGED.php`
- `slideData['background']` contract: `['path' => string, 'format' => 'JPG'|'PNG', 'width' => int, 'height' => int, 'bundleRelative' => bool(opt)]`
- `slideData['imageOnly'] = true` → skips text element entirely.
- Background action is appended BEFORE foreground media in actions array.
- New Slide accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
- Parser commit: `582ef85 feat(parser): background-layer + image-only slide support` in `/Users/thorsten/AI/propresenter` repo.
- All 372 parser tests green + 503 app tests green after vendor sync.
- Evidence: `.sisyphus/evidence/task-1-background-layer.txt`, `task-1-image-only.txt`
## Initial Setup
- Parser pkg at /Users/thorsten/AI/propresenter-work/php (PHPUnit 11, #[Test], ref/ fixtures)
- App at /Users/thorsten/AI/pp-planer (Pest v4 + PHPUnit, in-memory sqlite, ddev commands)
- FileConversionService uses Intervention v3 GD, contain(1920,1080,'000000','center')
- COVER new path: cover(1920,1080) center crop. Thumbnails 320x180 reused.
- Parser GAPS: LAYER_TYPE_BACKGROUND unused; no image-only slide; no text styling needed.
- Settings: Setting::get/set key-value; SettingsController AGENDA_KEYS constant; HandleInertiaRequests shares globals.
- ServiceAgendaItem: is_before_event bool, responsible json, sort_order, service_song_id.
- PlaylistExportService: iterates by sort_order; song items->ProExportService; slide items->addSlidesFromCollection.
## [2026-05-30] Task: T1
- Parser repo was at `/Users/thorsten/AI/propresenter` in this workspace (planned `/Users/thorsten/AI/propresenter-work/php` path was absent).
- Implemented `slideData['background'] = ['path' => string, 'format' => string, 'width' => int, 'height' => int]` to emit `ActionType::ACTION_TYPE_MEDIA` on `LayerType::LAYER_TYPE_BACKGROUND`.
- `slideData['imageOnly'] === true` skips text element generation even if `text` is present, producing background-only/image-only slides with 0 text elements.
- Slides may combine background media plus existing foreground `media`; cue action order is slide action, background media, foreground media so background precedes foreground media without breaking slide-element reads.
- `Slide` read accessors now distinguish foreground media from background media via layer type: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`.
## [2026-05-30] Task: T2
- `FileConversionService::convertImageCover()` now writes JPG slides with `cover(1920, 1080)` and thumbnail generation, returning `filename`, `thumbnail`, `warnings`, `fullCover => true`.
- Existing contain `convertImage()` keeps `contain(1920, 1080, '000000', 'center')` behavior and adds additive metadata `fullCover => false` for downstream export decisions.
- COVER warnings intentionally only include the German upscale/quality warning for sources smaller than 1920×1080, avoiding contain-specific black-bar wording.
- Evidence: `.sisyphus/evidence/task-2-cover-fill.txt`, `.sisyphus/evidence/task-2-contain-regression.txt`.
## [2026-05-30] Task: T3
- Migration filename: `2026_05_10_115900_add_image_fields_to_services_table.php`
- Columns added on `services`: `key_visual_filename`, `background_filename`, `moderator_name`, `preacher_name_override`
- `Service` accessors use `Attribute::get(fn () => $this->... ? '/storage/'.$this->... : null)` for `keyVisualUrl` and `backgroundUrl`
- Added all 4 new fields to `Service::$fillable` so `fill()`/`save()` persists them
- Full suite green after fresh migrate: `ddev exec php artisan test` → 510 passed
## [2026-05-30] Task: T4
- Setting keys added to `SettingsController::AGENDA_KEYS` constant (single source for both index() props and update() Rule::in validation): `current_key_visual`, `current_background`, `namenseinblender_macro_name`, `namenseinblender_macro_uuid`, `namenseinblender_macro_collection_name`, `namenseinblender_macro_collection_uuid`.
- PATCH /settings (route `settings.update`) validates `key` via `Rule::in(self::AGENDA_KEYS)`, `value` nullable string max:500, persists via `Setting::set()`.
- `HandleInertiaRequests::share()` now globally exposes: `namenseinblenderMacro` => {name, uuid, collection_name (default '--MAIN--'), collection_uuid}, plus `currentKeyVisual`, `currentBackground`.
- NOTE: namenseinblenderMacro uses snake_case keys (name/uuid/collection_name/collection_uuid) — differs from existing `macroSettings` which uses camelCase (collectionName/collectionUuid). T10/T14 consumers must use snake_case for namenseinblender.
- Test file `tests/Feature/NamenseinblenderSettingTest.php` (Pest, 4 tests). Inertia prop assertions use `->where('namenseinblenderMacro.name', ...)` dot-path on shared props from any authed page (settings.index).
- 514 app tests green (510 baseline + 4 new). Pint clean.
## [2026-05-31] Task: T5
- `App\Services\ServiceImageResolver` is pure and lazy: it only reads `Service`, `Setting::get(...)`, and `Storage::disk('public')->exists(...)`; it writes nothing.
- Resolution order is per-service filename first, then global setting filename, then `null`; missing files are skipped/fall through at both levels.
- `keyVisualFor()` uses `key_visual_filename` then `current_key_visual`; `backgroundFor()` uses `background_filename` then `current_background`.
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.
## [2026-05-31] Task: T6 — ServiceImageController
- New controller `app/Http/Controllers/ServiceImageController.php`: `storeKeyVisual()` + `storeBackground()` both delegate to private `store($request, $service, $column, $settingKey)`.
- Validation: `file` => required|file|mimes:jpg,jpeg,png|max:20480 (20MB); `scope` => required|Rule::in(['service','default']). German messages via 2nd arg to `$request->validate([...], [...])`.
- Conversion: `app(FileConversionService::class)->convertImageCover($request->file('file'))` → stores `$result['filename']` (slides/{uuid}.jpg) into `key_visual_filename`/`background_filename` via `$service->update([...])`.
- scope=default ALSO calls `Setting::set('current_key_visual'|'current_background', $result['filename'])`. scope=service leaves global Setting untouched.
- Old file NOT deleted on replace (protects finalized snapshots). Verified by test.
- Routes (inside auth group): `POST /services/{service}/key-visual``services.key-visual.store`; `POST /services/{service}/background``services.background.store`.
- Response: `back()->with('success', 'Bild wurde gespeichert.')` (Inertia redirect). Web validation failures redirect 302; tests use `postJson()` to assert the 422 JSON contract.
- GOTCHA: after adding controller, route cache + autoload were stale → `ddev exec composer dump-autoload && ddev exec php artisan optimize:clear`. Also the `use` import edit silently didn't stick first time — verify imports after editing routes/web.php.
- Test file `tests/Feature/ServiceImageControllerTest.php` (6 tests). Helper `makeImageUpload($name,$w,$h)` GD-based (same pattern as SlideControllerTest's makePngUploadForSlide).
- Full suite: 525 passed (519 baseline + 6). Pint clean.
- Evidence: `.sisyphus/evidence/task-6-default-upload.txt`, `task-6-invalid-upload.txt`.
## [2026-05-31] Task: T7 — NameTagResolver
- New service `App\Services\NameTagResolver`: `moderatorFor(Service): ?string`, `preacherFor(Service): ?string`.
- Moderator resolution: trimmed `services.moderator_name` wins; else first visible agenda item (`is_before_event=false`) ordered by `sort_order`, then `id`; responsible names joined by `', '`; no name => `null`.
- Preacher resolution: trimmed `services.preacher_name_override` wins; else trimmed `services.preacher_name`; else first visible non-song sermon agenda item (`service_song_id IS NULL`) responsible names; no name => `null`.
- `responsible` JSON findings: model casts to array; sync test proves associative object shape `{name: 'Max Mustermann'}`; expected multi-person shape is list of objects `[{name: 'Anna Müller'}, {name: 'Tom Klein'}]`. Resolver supports both plus string entries and `firstName`/`lastName` or snake_case fallbacks.
- Sermon detection method: use `Setting::get('agenda_sermon_matching')` comma patterns through `AgendaMatcherService::matchesAny()` (same matcher used by ServiceController/Service finalization). If no setting is configured, fallback checks title/type for `predigt` or `sermon`.
- Tests: `tests/Feature/NameTagResolverTest.php` covers override/fallback/null branches for moderator and preacher. Full suite green: `ddev exec php artisan test` → 532 passed (2659 assertions). Pint clean. Evidence: `.sisyphus/evidence/task-7-moderator.txt`, `.sisyphus/evidence/task-7-preacher.txt`.
## [2026-05-31] Task: T8 — Export background layer
- `ProExportService::buildGroups()` now resolves `ServiceImageResolver::backgroundFor($service)` once per song export and adds `slideData['background']` with `Storage::disk('public')->path(...)`, format JPG, 1920×1080 to every non-full-cover song slide.
- Sermon image exports use the same background contract in `PlaylistExportService` and `ProBundleExportService`; information/moderation slide exports explicitly remain without background.
- Full-cover detection is persisted with new nullable `slides.cover_mode` (`null` legacy/unknown, `false` contain, `true` cover). `SlideController` stores `$result['fullCover']` from image/ZIP conversions; existing contain conversions store `false`.
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.
## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered.
- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file.
- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`.
- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged.
- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`.
## [2026-05-31] Task: T10 — NameTagSlideBuilder
- New pure service `App\Services\NameTagSlideBuilder` builds ephemeral slideData only; it does not persist slides.
- `build()` gates solely on non-empty `Setting::get('namenseinblender_macro_name')`; no configured macro name returns `null` so callers skip nametag slides.
- Text contract is plain parser text string with exactly two lines: `$name."\n".$title`; convenience methods use `Moderation` and `Predigt`.
- Parser-facing macro uses camelCase keys: `name`, `uuid`, `collectionName`, `collectionUuid`; collection name defaults to `--MAIN--` via `Setting::get('namenseinblender_macro_collection_name', '--MAIN--')`.
- Tests: `tests/Feature/NameTagSlideBuilderTest.php` covers no-macro null, configured macro/text shape, and moderator/preacher titles. Full suite green: `ddev exec php artisan test` → 544 passed / 2703 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-10-nametag-macro.txt`, `.sisyphus/evidence/task-10-no-macro.txt`.
## Task 11: Sermon sequence + moderator injection
- Pint auto-removes same-namespace `use App\Services\X` imports (NameTagResolver/NameTagSlideBuilder are already in App\Services); rely on `app(Class::class)` resolution instead of importing.
- Sermon item WITH slides now ALWAYS gets a Keyvisual-Predigt .pro prepended (macro-independent), then Predigername nametag (only if macro set), then sermon slides. This changed KeyVisualFallbackTest expectation from 1 to 2 embedded .pro files.
- Moderator nametag injected as FIRST playlist entry for the first is_before_event=false agenda item (only if macro set).
- PlaylistArchive::getEntries() + entry->getName() is the way to assert playlist ORDER in tests.

107
AGENTS.md
View file

@ -140,6 +140,103 @@ ### Maintenance note
---
## KeyVisual & Background
Each service export can carry a key-visual image (shown as a standalone fallback slide) and a background image (rendered as a media layer behind every song/sermon slide).
Resolution is lazy and happens at export time:
1. Per-service column (`key_visual_filename` / `background_filename` on the `services` table)
2. Global default from Settings (`current_key_visual` / `current_background`)
3. None (slide omitted / no background layer)
When a service is finalized, the resolved filenames are snapshotted into the per-service columns so the export is stable even if the global default changes later.
### Export naming contract (portable bundles)
On export the key-visual and background images are **embedded into the archive** under fixed names and referenced **bundle-relative** inside the `.pro` file (never by absolute path), so exports are portable to the presenter PC:
| Image | Embedded filename + `.pro` reference |
|-------|--------------------------------------|
| Key-visual | `KEY_VISUAL.jpg` |
| Background | `BACKGROUND.jpg` |
The fixed names are defined as `ServiceImageResolver::KEY_VISUAL_EXPORT_NAME` / `ServiceImageResolver::BACKGROUND_EXPORT_NAME`. The `slideData['background']` array carries `'path' => '<FIXED_NAME>'` with `'bundleRelative' => true`; the image bytes are added to the archive's embedded/media files under that same name (deduplicated per archive). Applies to `.proplaylist` (`PlaylistExportService`) and `.probundle` (`ProBundleExportService`). The bare single-song `.pro` download has no service context and carries no background.
### Key files
| File | Purpose |
|------|---------|
| `app/Services/ServiceImageResolver.php` | Lazy resolution: per-service column → global Setting → null |
| `app/Http/Controllers/ServiceImageController.php` | `POST /services/{service}/key-visual` + `POST /services/{service}/background`; scope dialog ("Nur für diesen Service" / "Als Standard setzen") |
| `app/Services/FileConversionService.php` | Added `convertImageCover()` for COVER-mode 1920×1080 conversion |
| `app/Services/PlaylistExportService.php` | Injects keyvisual fallback slides and sermon sequence (keyvisual → nametag → slides) |
| `app/Services/ProExportService.php` | Adds background media layer on song/sermon slides |
| `resources/js/Components/ServiceImagePanel.vue` | Upload panel with scope dialog; used on the service Edit page |
| `resources/js/Pages/Services/Edit.vue` | Image panels rendered at the top of the edit form |
| `resources/js/Pages/Settings.vue` | Global default key-visual and background fields |
### Settings keys
| Key | Purpose |
|-----|---------|
| `current_key_visual` | Global default key-visual filename |
| `current_background` | Global default background filename |
### Routes
| Method | Route | Name |
|--------|-------|------|
| POST | `/services/{service}/key-visual` | `services.key-visual.store` |
| POST | `/services/{service}/background` | `services.background.store` |
### Parser package changes (commit `582ef85`)
- New `slideData['background']` contract: background-layer media action on any slide
- New `slideData['imageOnly']` flag: image-only slide (no text layer)
- New Slide read accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
---
## NameTag (Namenseinblender)
A name-tag slide is injected into the sermon sequence (between the key-visual and the sermon slides) to display the moderator and/or preacher name on screen.
The slide is only generated when the Namenseinblender macro is fully configured in Settings. It renders plain white text and optionally triggers a ProPresenter macro.
### Name resolution order
1. `moderator_name` / `preacher_name_override` columns on the `services` table (manual override set via Edit form)
2. Responsible person from the CTS `responsible` JSON field matching the configured role
3. None (slide omitted)
### Key files
| File | Purpose |
|------|---------|
| `app/Services/NameTagResolver.php` | Resolves moderator/preacher name: responsible JSON → CTS role → manual override |
| `app/Services/NameTagSlideBuilder.php` | Builds `slideData` for the nametag slide (plain white text + optional macro) |
| `app/Http/Controllers/ServiceController.php` | `PATCH /services/{service}/name-overrides` — saves manual name overrides |
| `resources/js/Pages/Services/Edit.vue` | Name override input fields in the edit form |
| `resources/js/Pages/Settings.vue` | Namenseinblender submenu with macro name/UUID/collection fields |
### Settings keys
| Key | Purpose |
|-----|---------|
| `namenseinblender_macro_name` | ProPresenter macro name |
| `namenseinblender_macro_uuid` | ProPresenter macro UUID |
| `namenseinblender_macro_collection_name` | Macro collection name |
| `namenseinblender_macro_collection_uuid` | Macro collection UUID |
### Routes
| Method | Route | Name |
|--------|-------|------|
| PATCH | `/services/{service}/name-overrides` | `services.name-overrides.update` |
---
## Repository Structure
Two git repositories, both local (no remote):
@ -147,9 +244,9 @@ ## Repository Structure
| Repo | Path | Branch | Purpose |
|------|------|--------|---------|
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
| **propresenter-work** | `/Users/thorsten/AI/propresenter-work/php` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
The parser is linked via `composer.json` path repository: `"url": "../propresenter-work/php"`.
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
## Build, Test, Lint Commands
@ -200,10 +297,10 @@ # Migrations
ddev exec php artisan migrate
```
### propresenter-work (Parser Module)
### propresenter (Parser Module)
```bash
cd /Users/thorsten/AI/propresenter-work/php
cd /Users/thorsten/AI/propresenter
# Run all tests (230 tests)
./vendor/bin/phpunit
@ -228,7 +325,7 @@ ## Architecture
tests/Feature/ # Pest v4 / PHPUnit feature tests
tests/e2e/ # Playwright browser tests (TypeScript)
propresenter-work/php/
propresenter/
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
tests/ # PHPUnit 11 tests with #[Test] attributes
ref/ # .pro fixture files for testing

View file

@ -5,9 +5,11 @@
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongSection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArrangementController extends Controller
{
@ -29,18 +31,18 @@ public function store(Request $request, Song $song): RedirectResponse
return;
}
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
$arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get();
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
$rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [
'song_arrangement_id' => $arrangement->id,
'label_id' => $al->label_id,
'song_section_id' => $arrangementSection->song_section_id,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
])->all();
if ($rows !== []) {
$arrangement->arrangementLabels()->insert($rows);
$arrangement->arrangementSections()->insert($rows);
}
});
@ -54,7 +56,7 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
]);
DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementLabels');
$arrangement->loadMissing('arrangementSections');
$clone = $arrangement->song->arrangements()->create([
'name' => $data['name'],
@ -71,22 +73,23 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
{
$data = $request->validate([
'groups' => ['array'],
'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'],
'groups.*.section_id' => ['nullable', 'integer', 'exists:song_sections,id'],
'groups.*.label_id' => ['nullable', 'integer', 'exists:labels,id'],
'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
$sectionIds = $this->sectionIdsForGroups($arrangement, $data['groups'] ?? []);
DB::transaction(function () use ($arrangement, $labelIds, $data): void {
$arrangement->arrangementLabels()->delete();
DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
$arrangement->arrangementSections()->delete();
$rows = $labelIds
$rows = $sectionIds
->values()
->map(fn (int $labelId, int $index) => [
->map(fn (int $sectionId, int $index) => [
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelId,
'song_section_id' => $sectionId,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
@ -94,12 +97,19 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
->all();
if ($rows !== []) {
$arrangement->arrangementLabels()->insert($rows);
$arrangement->arrangementSections()->insert($rows);
}
if (! empty($data['group_colors'])) {
foreach ($data['group_colors'] as $labelId => $color) {
Label::whereKey((int) $labelId)->update(['color' => $color]);
$sections = SongSection::whereIn('id', collect(array_keys($data['group_colors']))->map(fn ($id) => (int) $id))
->get()
->keyBy('id');
foreach ($data['group_colors'] as $id => $color) {
$section = $sections->get((int) $id);
$labelId = $section?->label_id ?? (int) $id;
Label::whereKey($labelId)->update(['color' => $color]);
}
}
});
@ -136,22 +146,56 @@ private function cloneArrangementLabels(?SongArrangement $source, SongArrangemen
return;
}
$arrangementLabels = $source->arrangementLabels
$arrangementSections = $source->arrangementSections
->sortBy('order')
->values();
$rows = $arrangementLabels
->map(fn ($arrangementLabel) => [
$rows = $arrangementSections
->map(fn ($arrangementSection) => [
'song_arrangement_id' => $target->id,
'label_id' => $arrangementLabel->label_id,
'order' => $arrangementLabel->order,
'song_section_id' => $arrangementSection->song_section_id,
'order' => $arrangementSection->order,
'created_at' => now(),
'updated_at' => now(),
])
->all();
if ($rows !== []) {
$target->arrangementLabels()->insert($rows);
$target->arrangementSections()->insert($rows);
}
}
private function sectionIdsForGroups(SongArrangement $arrangement, array $groups): \Illuminate\Support\Collection
{
$songId = $arrangement->song_id;
$sectionIds = collect($groups)->map(function (array $group) use ($songId) {
if (isset($group['section_id'])) {
$section = SongSection::find((int) $group['section_id']);
if ($section === null || (int) $section->song_id !== (int) $songId) {
throw ValidationException::withMessages([
'groups' => 'Diese Sektion gehört nicht zu diesem Song.',
]);
}
return $section->id;
}
if (isset($group['label_id'])) {
$section = SongSection::where('song_id', $songId)
->where('label_id', (int) $group['label_id'])
->first();
if ($section !== null) {
return $section->id;
}
}
throw ValidationException::withMessages([
'groups' => 'Bitte wähle gültige Song-Sektionen aus.',
]);
})->values();
return $sectionIds;
}
}

View file

@ -2,14 +2,19 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Config;
final class BookmarkletController extends Controller
{
public function show(): Response
public function show(Request $request): Response
{
$appUrl = rtrim($request->getSchemeAndHttpHost(), '/');
if ($appUrl === '') {
$appUrl = rtrim((string) Config::get('app.url', ''), '/');
}
$bookmarkletScript = <<<'BOOKMARKLET'
(function(){
@ -18,20 +23,42 @@ public function show(): Response
alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).');
return;
}
var title = (document.querySelector('h1, .song-title, [class*="title"]') || {}).innerText || document.title || '';
var author = (document.querySelector('.song-authors, .song-artist, [class*="author"]') || {}).innerText || '';
var bodyText = document.body ? document.body.innerText : '';
var ccliMatch = bodyText.match(/CCLI[\s#-]*(\d+)/i);
var ccliId = ccliMatch ? ccliMatch[1] : '';
function send(text){
var ccliMatch = (text || '').match(/CCLI[\s#-]*(\d+)/i);
var payload = {
title: title.trim(),
author: author.trim(),
ccliId: ccliId,
title: '',
author: '',
ccliId: ccliMatch ? ccliMatch[1] : '',
sourceUrl: location.href,
rawText: bodyText
rawText: text || ''
};
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank');
}
var btn = document.querySelector('#generalCopyLyricsButton');
if(!btn){
alert('Kopier-Symbol nicht gefunden. Bitte öffne die Liedtext-Ansicht auf SongSelect und versuche es erneut.');
return;
}
var captured = null;
function onCopy(e){
try { captured = e.clipboardData.getData('text/plain'); } catch(err) {}
}
document.addEventListener('copy', onCopy, true);
btn.click();
setTimeout(function(){
document.removeEventListener('copy', onCopy, true);
if(captured && captured.trim()){
send(captured);
return;
}
if(navigator.clipboard && navigator.clipboard.readText){
navigator.clipboard.readText().then(function(text){ send(text); })
.catch(function(){ alert('Liedtext konnte nicht aus der Zwischenablage gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".'); });
} else {
alert('Liedtext konnte nicht gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".');
}
}, 250);
})();
BOOKMARKLET;

View file

@ -126,21 +126,23 @@ public function index(): Response
]);
}
public function edit(Service $service): Response
public function edit(Service $service, \App\Services\ServiceImageResolver $imageResolver): Response
{
$service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song',
'serviceSongs.song.arrangements.arrangementLabels.label',
'serviceSongs.song.arrangements.arrangementSections.section.label',
'serviceSongs.arrangement',
'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides',
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'agendaItems.serviceSong.arrangement.arrangementLabels.label',
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
]);
$songsCatalog = Song::query()
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')])
->orderBy('title')
->get(['id', 'title', 'ccli_id', 'has_translation'])
->map(fn (Song $song) => [
@ -148,6 +150,7 @@ public function edit(Service $service): Response
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation,
'has_content' => (int) $song->content_slides_count > 0,
])
->values();
@ -255,6 +258,13 @@ public function edit(Service $service): Response
];
}
// Resolve key-visual/background live (per-service override → current global default → none),
// so the panels always reflect the CURRENT default even if it changed after creation/sync.
$resolvedKeyVisual = $imageResolver->keyVisualFor($service);
$resolvedBackground = $imageResolver->backgroundFor($service);
$keyVisualIsOwn = $service->key_visual_filename !== null && $resolvedKeyVisual === $service->key_visual_filename;
$backgroundIsOwn = $service->background_filename !== null && $resolvedBackground === $service->background_filename;
return Inertia::render('Services/Edit', [
'service' => [
'id' => $service->id,
@ -265,6 +275,14 @@ public function edit(Service $service): Response
'finalized_at' => $service->finalized_at?->toJSON(),
'last_synced_at' => $service->last_synced_at?->toJSON(),
'has_agenda' => $service->has_agenda,
'key_visual_filename' => $resolvedKeyVisual,
'background_filename' => $resolvedBackground,
'key_visual_url' => $resolvedKeyVisual ? '/storage/'.$resolvedKeyVisual : null,
'background_url' => $resolvedBackground ? '/storage/'.$resolvedBackground : null,
'key_visual_is_own' => $keyVisualIsOwn,
'background_is_own' => $backgroundIsOwn,
'moderator_name' => $service->moderator_name,
'preacher_name_override' => $service->preacher_name_override,
],
'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
'id' => $ss->id,
@ -289,13 +307,14 @@ public function edit(Service $service): Response
'id' => $arrangement->id,
'name' => $arrangement->name,
'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementLabels
'groups' => $arrangement->arrangementSections
->sortBy('order')
->values()
->map(fn ($arrangementLabel) => [
'id' => $arrangementLabel->label?->id,
'name' => $arrangementLabel->label?->name,
'color' => $arrangementLabel->label?->color,
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->label?->id,
'section_id' => $arrangementSection->section?->id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
])
->filter(fn ($group) => $group['id'] !== null)
->values(),
@ -343,12 +362,38 @@ public function finalize(Service $service): JsonResponse
'finalized_at' => now(),
]);
$resolver = app(\App\Services\ServiceImageResolver::class);
$keyVisual = $resolver->keyVisualFor($service);
$background = $resolver->backgroundFor($service);
$updates = [];
if ($keyVisual !== null && $service->key_visual_filename === null) {
$updates['key_visual_filename'] = $keyVisual;
}
if ($background !== null && $service->background_filename === null) {
$updates['background_filename'] = $background;
}
if ($updates !== []) {
$service->update($updates);
}
return response()->json([
'needs_confirmation' => false,
'success' => 'Service wurde abgeschlossen.',
]);
}
public function updateNameOverrides(Request $request, Service $service): RedirectResponse
{
$validated = $request->validate([
'moderator_name' => ['nullable', 'string', 'max:255'],
'preacher_name_override' => ['nullable', 'string', 'max:255'],
]);
$service->update($validated);
return back()->with('success', 'Namensangaben gespeichert.');
}
public function reopen(Service $service): RedirectResponse
{
$service->update([
@ -442,14 +487,15 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection
return collect();
}
return $defaultArr->arrangementLabels
return $defaultArr->arrangementSections
->sortBy('order')
->values()
->map(fn ($arrangementLabel) => [
'id' => $arrangementLabel->label?->id,
'name' => $arrangementLabel->label?->name,
'color' => $arrangementLabel->label?->color,
'order' => $arrangementLabel->order,
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->label?->id,
'section_id' => $arrangementSection->section?->id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order,
])
->filter(fn ($group) => $group['id'] !== null)
->values();

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\Setting;
use App\Services\FileConversionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ServiceImageController extends Controller
{
public function storeKeyVisual(Request $request, Service $service): RedirectResponse
{
return $this->store($request, $service, 'key_visual_filename', 'current_key_visual');
}
public function storeBackground(Request $request, Service $service): RedirectResponse
{
return $this->store($request, $service, 'background_filename', 'current_background');
}
private function store(Request $request, Service $service, string $column, string $settingKey): RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:20480'],
'scope' => ['required', Rule::in(['service', 'default'])],
], [
'file.required' => 'Bitte wähle eine Bilddatei aus.',
'file.file' => 'Die hochgeladene Datei ist ungültig.',
'file.mimes' => 'Nur Bilddateien (jpg, png) sind erlaubt.',
'file.max' => 'Die Datei darf maximal 20 MB groß sein.',
'scope.required' => 'Bitte wähle einen Geltungsbereich.',
'scope.in' => 'Der gewählte Geltungsbereich ist ungültig.',
]);
$result = app(FileConversionService::class)->convertImageCover($request->file('file'));
$service->update([$column => $result['filename']]);
if ($request->input('scope') === 'default') {
Setting::set($settingKey, $result['filename']);
}
return back()->with('success', 'Bild wurde gespeichert.');
}
}

View file

@ -21,6 +21,12 @@ class SettingsController extends Controller
'agenda_announcement_position',
'agenda_sermon_matching',
'default_translation_language',
'current_key_visual',
'current_background',
'namenseinblender_macro_name',
'namenseinblender_macro_uuid',
'namenseinblender_macro_collection_name',
'namenseinblender_macro_collection_uuid',
];
public function index(): Response

View file

@ -169,6 +169,7 @@ private function handleImage(
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'cover_mode' => $result['fullCover'] ?? null,
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
@ -260,6 +261,7 @@ private function handleZip(
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'cover_mode' => $result['fullCover'] ?? null,
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\SongRequest;
use App\Models\Label;
use App\Models\Song;
use App\Services\SongService;
use Illuminate\Http\JsonResponse;
@ -17,7 +18,8 @@ public function __construct(
public function index(Request $request): JsonResponse
{
$query = Song::query();
$query = Song::query()
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')]);
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
@ -26,6 +28,12 @@ public function index(Request $request): JsonResponse
});
}
// The SongDB UI sends with_content=1 by default to hide songs without content;
// pass with_content=0 (or omit) to include empty songs.
if ($request->boolean('with_content')) {
$query->whereHas('sections', fn ($q) => $q->has('slides'));
}
$songs = $query->orderBy('title')
->paginate($request->input('per_page', 20));
@ -36,6 +44,7 @@ public function index(Request $request): JsonResponse
'ccli_id' => $song->ccli_id,
'author' => $song->author,
'has_translation' => $song->has_translation,
'has_content' => (int) $song->content_slides_count > 0,
'last_used_at' => $song->last_used_at?->toDateString(),
'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(),
@ -62,13 +71,13 @@ public function store(SongRequest $request): JsonResponse
return response()->json([
'message' => 'Song erfolgreich erstellt',
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
], 201);
}
public function show(int $id): JsonResponse
{
$song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id);
$song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
@ -91,7 +100,7 @@ public function update(SongRequest $request, int $id): JsonResponse
return response()->json([
'message' => 'Song erfolgreich aktualisiert',
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
]);
}
@ -110,22 +119,24 @@ public function destroy(int $id): JsonResponse
]);
}
private function formatSongDetail(Song $song): array
public function formatSongDetail(Song $song): array
{
$defaultArr = $song->arrangements->firstWhere('is_default', true);
$groupsPayload = [];
if ($defaultArr !== null) {
$groupsPayload = $defaultArr->arrangementLabels
$groupsPayload = $defaultArr->arrangementSections
->sortBy('order')
->values()
->map(fn ($al) => [
'id' => $al->label?->id,
'name' => $al->label?->name,
'color' => $al->label?->color,
'order' => $al->order,
'slides' => $al->label
? $al->label->songSlides
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->id,
'section_id' => $arrangementSection->section?->id,
'label_id' => $arrangementSection->section?->label_id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order,
'slides' => $arrangementSection->section
? $arrangementSection->section->slides
->sortBy('order')
->values()
->map(fn ($slide) => [
@ -153,14 +164,24 @@ private function formatSongDetail(Song $song): array
'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(),
'groups' => $groupsPayload,
'available_labels' => Label::query()
->whereNull('hidden_at')
->orderBy('name')
->get(['id', 'name', 'color'])
->map(fn (Label $label) => [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
])->toArray(),
'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id,
'name' => $arr->name,
'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
'id' => $al->id,
'label_id' => $al->label_id,
'order' => $al->order,
'arrangement_groups' => $arr->arrangementSections->sortBy('order')->values()->map(fn ($arrangementSection) => [
'id' => $arrangementSection->id,
'section_id' => $arrangementSection->song_section_id,
'label_id' => $arrangementSection->section?->label_id,
'order' => $arrangementSection->order,
])->toArray(),
])->toArray(),
];

View file

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

View file

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use App\Support\CcliLabels;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SongSectionController extends Controller
{
private const DEFAULT_LABEL_COLOR = '#3B82F6';
public function __construct(
private readonly SongController $songController,
) {}
public function store(Request $request, Song $song): JsonResponse
{
$data = $request->validate([
'label_name' => ['required', 'string', 'max:255'],
'color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'slides' => ['array'],
'slides.*.text_content' => ['required', 'string'],
'slides.*.text_content_translated' => ['nullable', 'string'],
], $this->validationMessages());
$normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']);
$responseSong = DB::transaction(function () use ($song, $data, $normalizedLabelName): Song {
$label = Label::firstOrCreate(
['name' => $normalizedLabelName],
['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR],
);
if ($song->sections()->where('label_id', $label->id)->exists()) {
abort(response()->json([
'message' => 'Dieser Abschnitt existiert bereits in diesem Lied.',
], 422));
}
$section = $song->sections()->create([
'label_id' => $label->id,
'order' => ((int) $song->sections()->max('order')) + 1,
]);
$this->replaceSlides($section, $data['slides'] ?? []);
$defaultArrangement = $song->arrangements()->firstOrCreate(
['is_default' => true],
['name' => 'Normal'],
);
$defaultArrangement->arrangementSections()->create([
'song_section_id' => $section->id,
'order' => ((int) $defaultArrangement->arrangementSections()->max('order')) + 1,
]);
$this->recomputeHasTranslation($song);
return $this->freshSong($song);
});
return response()->json([
'message' => 'Sektion wurde hinzugefügt.',
'data' => $this->songController->formatSongDetail($responseSong),
], 201);
}
public function update(Request $request, Song $song, SongSection $section): JsonResponse
{
if ((int) $section->song_id !== (int) $song->id) {
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
}
$data = $request->validate([
'slides' => ['required', 'array'],
'slides.*.text_content' => ['required', 'string'],
'slides.*.text_content_translated' => ['nullable', 'string'],
'order' => ['sometimes', 'integer'],
], $this->validationMessages());
$responseSong = DB::transaction(function () use ($song, $section, $data): Song {
if (array_key_exists('order', $data)) {
$section->update(['order' => $data['order']]);
}
$this->replaceSlides($section, $data['slides']);
$this->recomputeHasTranslation($song);
return $this->freshSong($song);
});
return response()->json([
'message' => 'Sektion wurde gespeichert.',
'data' => $this->songController->formatSongDetail($responseSong),
]);
}
public function destroy(Song $song, SongSection $section): JsonResponse
{
if ((int) $section->song_id !== (int) $song->id) {
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
}
$responseSong = DB::transaction(function () use ($song, $section): Song {
SongArrangementSection::query()
->where('song_section_id', $section->id)
->whereHas('arrangement', fn ($query) => $query->where('song_id', $song->id))
->delete();
$section->slides()->delete();
$section->delete();
$this->recomputeHasTranslation($song);
return $this->freshSong($song);
});
return response()->json([
'message' => 'Sektion wurde gelöscht.',
'data' => $this->songController->formatSongDetail($responseSong),
]);
}
private function replaceSlides(SongSection $section, array $slides): void
{
$section->slides()->delete();
foreach (array_values($slides) as $index => $slide) {
$section->slides()->create([
'order' => $index + 1,
'text_content' => $slide['text_content'],
'text_content_translated' => $slide['text_content_translated'] ?? null,
]);
}
}
private function recomputeHasTranslation(Song $song): void
{
$hasTranslation = $song->sections()
->whereHas('slides', fn ($query) => $query
->whereNotNull('text_content_translated')
->where('text_content_translated', '!=', ''))
->exists();
$song->update(['has_translation' => $hasTranslation]);
}
private function freshSong(Song $song): Song
{
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
}
private function validationMessages(): array
{
return [
'label_name.required' => 'Bitte gib einen Namen für die Sektion ein.',
'label_name.string' => 'Der Sektionsname muss ein Text sein.',
'label_name.max' => 'Der Sektionsname darf höchstens 255 Zeichen lang sein.',
'color.regex' => 'Bitte gib eine gültige Hex-Farbe an.',
'slides.required' => 'Bitte gib mindestens eine Folie an.',
'slides.array' => 'Die Folien müssen als Liste gesendet werden.',
'slides.*.text_content.required' => 'Bitte gib einen Text für jede Folie ein.',
'slides.*.text_content.string' => 'Der Folientext muss ein Text sein.',
'slides.*.text_content_translated.string' => 'Der übersetzte Folientext muss ein Text sein.',
'order.integer' => 'Die Reihenfolge muss eine Zahl sein.',
];
}
}

View file

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

View file

@ -53,6 +53,14 @@ public function share(Request $request): array
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
],
'namenseinblenderMacro' => [
'name' => Setting::get('namenseinblender_macro_name'),
'uuid' => Setting::get('namenseinblender_macro_uuid'),
'collection_name' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
'collection_uuid' => Setting::get('namenseinblender_macro_collection_uuid'),
],
'currentKeyVisual' => Setting::get('current_key_visual'),
'currentBackground' => Setting::get('current_background'),
];
}
}

View file

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

View file

@ -17,7 +17,11 @@ class Service extends Model
'title',
'date',
'preacher_name',
'preacher_name_override',
'beamer_tech_name',
'key_visual_filename',
'background_filename',
'moderator_name',
'finalized_at',
'last_synced_at',
'cts_data',
@ -45,6 +49,16 @@ public function slides(): HasMany
return $this->hasMany(Slide::class);
}
protected function keyVisualUrl(): Attribute
{
return Attribute::get(fn () => $this->key_visual_filename ? '/storage/'.$this->key_visual_filename : null);
}
protected function backgroundUrl(): Attribute
{
return Attribute::get(fn () => $this->background_filename ? '/storage/'.$this->background_filename : null);
}
public function agendaItems(): HasMany
{
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');

View file

@ -19,6 +19,7 @@ class Slide extends Model
'original_filename',
'stored_filename',
'thumbnail_filename',
'cover_mode',
'expire_date',
'uploader_name',
'uploaded_at',
@ -30,6 +31,7 @@ protected function casts(): array
return [
'expire_date' => 'date',
'uploaded_at' => 'datetime',
'cover_mode' => 'boolean',
];
}

View file

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

View file

@ -31,7 +31,12 @@ public function song(): BelongsTo
public function arrangementLabels(): HasMany
{
return $this->hasMany(SongArrangementLabel::class)->orderBy('order');
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
}
public function arrangementSections(): HasMany
{
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
}
public function serviceSongs(): HasMany

View file

@ -2,27 +2,4 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SongArrangementLabel extends Model
{
use HasFactory;
protected $fillable = [
'song_arrangement_id',
'label_id',
'order',
];
public function arrangement(): BelongsTo
{
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
}
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
}
class SongArrangementLabel extends SongArrangementSection {}

View file

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

View file

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SongSection extends Model
{
use HasFactory;
protected $fillable = [
'song_id',
'label_id',
'order',
];
protected function casts(): array
{
return [
'order' => 'integer',
];
}
public function song(): BelongsTo
{
return $this->belongsTo(Song::class);
}
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
public function slides(): HasMany
{
return $this->hasMany(SongSlide::class, 'song_section_id')->orderBy('order');
}
}

View file

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

View file

@ -9,7 +9,7 @@
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\SongSection;
use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong;
use App\Support\CcliLabels;
@ -18,7 +18,23 @@
final class CcliImportService
{
private const DEFAULT_LABEL_COLOR = '#3B82F6';
/**
* Number of lyric lines grouped into a single projection slide.
*/
private const LINES_PER_SLIDE = 2;
private const LABEL_KIND_COLORS = [
'Verse' => '#3B82F6',
'Chorus' => '#10B981',
'Bridge' => '#F59E0B',
'Pre-Chorus' => '#8B5CF6',
'Tag' => '#EC4899',
'Ending' => '#EF4444',
'Intro' => '#14B8A6',
'Interlude' => '#6366F1',
'Outro' => '#F97316',
'Misc' => '#64748B',
];
public function __construct(
private readonly CcliPasteParser $parser,
@ -37,7 +53,7 @@ public function import(string $rawText, ?string $sourceUrl = null): array
$song = Song::withTrashed()->where('ccli_id', $parsed->ccliId)->first();
$status = 'created';
if ($song !== null && ! $song->trashed()) {
if ($song !== null && ! $song->trashed() && $this->songHasContent($song)) {
throw new DuplicateCcliSongException($song->id);
}
@ -58,23 +74,34 @@ public function import(string $rawText, ?string $sourceUrl = null): array
$warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.';
}
$labelIds = [];
$sectionIds = [];
$hasTranslation = false;
foreach ($parsed->sections as $section) {
$label = $this->resolveLabel($section);
$labelIds[] = $label->id;
foreach ($parsed->sections as $order => $parsedSection) {
$label = $this->resolveLabel($parsedSection);
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $label->id],
['order' => $order + 1],
);
$section->update(['order' => $order + 1]);
$sectionIds[] = $section->id;
$label->songSlides()->delete();
$section->slides()->delete();
foreach ($section->lines as $order => $line) {
$translatedLine = $section->linesTranslated[$order] ?? null;
// Group lines into pairs: each slide carries up to two lines.
$lineChunks = array_chunk($parsedSection->lines, self::LINES_PER_SLIDE);
$translatedChunks = $parsedSection->linesTranslated !== null
? array_chunk($parsedSection->linesTranslated, self::LINES_PER_SLIDE)
: [];
foreach ($lineChunks as $slideOrder => $chunk) {
$translatedChunk = $translatedChunks[$slideOrder] ?? null;
$translatedLine = $translatedChunk !== null ? implode("\n", $translatedChunk) : null;
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
SongSlide::create([
'label_id' => $label->id,
'order' => $order + 1,
'text_content' => $line,
$section->slides()->create([
'order' => $slideOrder + 1,
'text_content' => implode("\n", $chunk),
'text_content_translated' => $translatedLine,
]);
}
@ -93,15 +120,15 @@ public function import(string $rawText, ?string $sourceUrl = null): array
SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete();
foreach ($labelIds as $order => $labelId) {
foreach ($sectionIds as $order => $sectionId) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelId,
'song_section_id' => $sectionId,
'order' => $order + 1,
]);
}
$song = $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
ApiRequestLog::create([
'method' => 'import',
@ -137,15 +164,32 @@ private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $s
return Song::create(array_merge($songData, ['ccli_id' => $parsed->ccliId]));
}
private function songHasContent(Song $song): bool
{
return $song->sections()->whereHas('slides')->exists();
}
private function resolveLabel(ParsedCcliSection $section): Label
{
$canonicalKind = CcliLabels::normalizeLabelName($section->kind);
$canonicalLabelName = CcliLabels::normalizeLabelName(
$section->kind.($section->number ? ' '.$section->number : ''),
);
return Label::firstOrCreate(
['name' => $canonicalLabelName],
['color' => self::DEFAULT_LABEL_COLOR, 'last_imported_at' => now()],
['color' => $this->labelColor($canonicalKind), 'last_imported_at' => now()],
);
}
private function labelColor(string $canonicalKind): string
{
if (array_key_exists($canonicalKind, self::LABEL_KIND_COLORS)) {
return self::LABEL_KIND_COLORS[$canonicalKind];
}
$colors = array_values(self::LABEL_KIND_COLORS);
return $colors[crc32($canonicalKind) % count($colors)];
}
}

View file

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

View file

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

View file

@ -56,6 +56,45 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array
'filename' => $relativePath,
'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
'fullCover' => false,
];
}
public function convertImageCover(UploadedFile|string|SplFileInfo $file, string $disk = 'public'): array
{
$sourcePath = $this->resolvePath($file);
$extension = $this->resolveExtension($file, $sourcePath);
$this->assertSupported($extension);
if (! in_array($extension, self::IMAGE_EXTENSIONS, true)) {
throw new InvalidArgumentException('Nur Bilddateien koennen mit convertImageCover verarbeitet werden.');
}
$this->assertSize($file, $sourcePath);
$filename = Str::uuid()->toString().'.jpg';
$relativePath = 'slides/'.$filename;
$targetPath = Storage::disk($disk)->path($relativePath);
Storage::disk($disk)->makeDirectory('slides');
$this->ensureDirectory(dirname($targetPath));
$manager = $this->createImageManager();
$image = $manager->read($sourcePath);
$originalWidth = $image->width();
$originalHeight = $image->height();
$warnings = $this->checkCoverImageDimensions($originalWidth, $originalHeight);
$image->cover(1920, 1080);
$image->save($targetPath, quality: 90);
$thumbnailPath = $this->generateThumbnail($relativePath, $disk);
return [
'filename' => $relativePath,
'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
'fullCover' => true,
];
}
@ -143,11 +182,11 @@ public function processZip(UploadedFile|string|SplFileInfo $file): array
return $results;
}
public function generateThumbnail(string $path): string
public function generateThumbnail(string $path, string $disk = 'public'): string
{
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
? $path
: Storage::disk('public')->path($path);
: Storage::disk($disk)->path($path);
if (! is_file($absolutePath)) {
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
@ -155,8 +194,8 @@ public function generateThumbnail(string $path): string
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
$thumbnailRelativePath = 'slides/thumbnails/'.$filename;
$thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath);
Storage::disk('public')->makeDirectory('slides/thumbnails');
$thumbnailAbsolutePath = Storage::disk($disk)->path($thumbnailRelativePath);
Storage::disk($disk)->makeDirectory('slides/thumbnails');
$this->ensureDirectory(dirname($thumbnailAbsolutePath));
$manager = $this->createImageManager();
@ -279,6 +318,20 @@ private function checkImageDimensions(int $width, int $height): array
return $warnings;
}
/** @return string[] */
private function checkCoverImageDimensions(int $width, int $height): array
{
$warnings = [];
if ($width < 1920 || $height < 1080) {
$warnings[] = "Das Bild ({$width}×{$height}) ist kleiner als 1920×1080 und wurde hochskaliert. "
.'Dadurch kann die Qualität schlechter sein. '
.'Lade am besten Bilder mit mindestens 1920×1080 Pixeln hoch.';
}
return $warnings;
}
private function ensureDirectory(string $directory): void
{
if (is_dir($directory)) {

View file

@ -0,0 +1,130 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NameTagResolver
{
public function __construct(
private readonly AgendaMatcherService $agendaMatcherService,
) {}
public function moderatorFor(Service $service): ?string
{
$override = $this->filledString($service->moderator_name);
if ($override !== null) {
return $override;
}
$firstAgendaItem = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
->orderBy('id')
->first();
return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null;
}
public function preacherFor(Service $service): ?string
{
$override = $this->filledString($service->preacher_name_override);
if ($override !== null) {
return $override;
}
$preacherName = $this->filledString($service->preacher_name);
if ($preacherName !== null) {
return $preacherName;
}
$sermonItem = $service->agendaItems()
->where('is_before_event', false)
->whereNull('service_song_id')
->orderBy('sort_order')
->orderBy('id')
->get()
->first(fn (ServiceAgendaItem $item) => $this->isSermonItem($item));
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null;
}
private function filledString(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed === '' ? null : $trimmed;
}
private function namesFromResponsible(mixed $responsible): ?string
{
if (! is_array($responsible) || $responsible === []) {
return null;
}
$people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
$names = collect($people)
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
->filter()
->values()
->all();
return $names === [] ? null : implode(', ', $names);
}
private function nameFromResponsiblePerson(mixed $person): ?string
{
if (is_string($person)) {
return $this->filledString($person);
}
if (! is_array($person)) {
return null;
}
$name = $this->filledString($person['name'] ?? null);
if ($name !== null) {
return $name;
}
$firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? '';
$lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? '';
$fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName;
}
private function isSermonItem(ServiceAgendaItem $item): bool
{
$configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching'));
if ($configuredPatterns !== []) {
return $this->agendaMatcherService->matchesAny($item->title, $configuredPatterns);
}
$title = Str::lower($item->title);
$type = Str::lower($item->type ?? '');
return str_contains($title, 'predigt')
|| str_contains($title, 'sermon')
|| str_contains($type, 'predigt')
|| str_contains($type, 'sermon');
}
/** @return array<int, string> */
private function patternsFromSetting(?string $patterns): array
{
if ($patterns === null || trim($patterns) === '') {
return [];
}
return array_values(array_filter(
array_map(fn (string $pattern) => trim($pattern), explode(',', $patterns)),
fn (string $pattern) => $pattern !== '',
));
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Services;
use App\Models\Setting;
class NameTagSlideBuilder
{
public function buildModeratorSlide(string $name): ?array
{
return $this->build($name, 'Moderation');
}
public function buildPreacherSlide(string $name): ?array
{
return $this->build($name, 'Predigt');
}
public function build(string $name, string $title): ?array
{
$macroName = Setting::get('namenseinblender_macro_name');
if ($macroName === null || trim($macroName) === '') {
return null;
}
return [
'text' => $name."\n".$title,
'macro' => [
'name' => $macroName,
'uuid' => Setting::get('namenseinblender_macro_uuid'),
'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'),
],
];
}
}

View file

@ -21,8 +21,9 @@ public function generatePlaylist(Service $service): array
->orderBy('sort_order')
->with([
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'serviceSong.arrangement.arrangementLabels.label',
'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.song.arrangements.arrangementSections.section.label',
'serviceSong.arrangement.arrangementSections.section.label',
])
->get();
@ -61,7 +62,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$embeddedFiles = [];
$skippedUnmatched = 0;
$moderatorSlideData = $this->buildModeratorSlideData($service);
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
foreach ($agendaItems as $item) {
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
$this->writeProAndEmbed(
'Moderator',
$moderatorSlideData,
$tempDir,
$playlistItems,
$embeddedFiles,
);
}
if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
$patterns = array_map('trim', explode(',', $announcementPatterns));
$matcher = app(AgendaMatcherService::class);
@ -73,6 +87,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
'information',
);
$announcementInserted = true;
}
@ -96,6 +112,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
rename($proPath, $destPath);
$embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [
'type' => 'presentation',
@ -110,6 +127,11 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
}
if ($item->slides->isNotEmpty()) {
if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') {
$this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt');
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
}
$label = $item->title ?: 'Folien';
$this->addSlidesFromCollection(
$item->slides,
@ -118,6 +140,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$this->backgroundPartTypeForAgendaItem($item),
);
continue;
}
if (! $this->isNameTagAgendaItem($item)) {
$this->addKeyVisualFallbackPresentation(
$item,
$service,
$tempDir,
$playlistItems,
$embeddedFiles,
);
}
}
@ -132,6 +168,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$prependItems,
$prependFiles,
$service,
'information',
);
$playlistItems = array_merge($prependItems, $playlistItems);
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
@ -165,12 +203,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
*/
private function generatePlaylistLegacy(Service $service): array
{
$service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
$matchedSongs = $service->serviceSongs()
->whereNotNull('song_id')
->orderBy('order')
->with('song.arrangements.arrangementLabels.label.songSlides')
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
@ -207,6 +245,7 @@ private function generatePlaylistLegacy(Service $service): array
rename($proPath, $destPath);
$embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [
'type' => 'presentation',
@ -260,9 +299,13 @@ private function addSlidesFromCollection(
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
?Service $service = null,
?string $backgroundPartType = null,
): void {
$slideDataList = [];
$imageFiles = [];
$background = $this->backgroundData($service);
$backgroundAttached = false;
foreach ($slides->values() as $index => $slide) {
$storedPath = Storage::disk('public')->path($slide->stored_filename);
@ -277,11 +320,22 @@ private function addSlidesFromCollection(
$imageFiles[$imageFilename] = file_get_contents($destPath);
$slideDataList[] = [
$singleSlideData = [
'media' => $imageFilename,
'format' => 'JPG',
'label' => $slide->original_filename,
];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background;
$backgroundAttached = true;
}
$slideDataList[] = $singleSlideData;
}
if ($backgroundAttached) {
$this->embedBackground($service, $embeddedFiles);
}
if (empty($slideDataList)) {
@ -362,14 +416,120 @@ private function addSlidePresentation(
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$type,
);
}
private function addKeyVisualFallbackPresentation(
ServiceAgendaItem $item,
Service $service,
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
): void {
$background = $this->keyVisualData($service);
if ($background === null) {
return;
}
$this->embedKeyVisual($service, $embeddedFiles);
$label = $item->title ?: 'Keyvisual';
$groups = [
[
'name' => 'Keyvisual',
'color' => [0, 0, 0, 1],
'slides' => [
[
'imageOnly' => true,
'background' => $background,
],
],
],
];
$arrangements = [
[
'name' => 'normal',
'groupNames' => ['Keyvisual'],
],
];
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
$proFilename = $safeLabel.'.pro';
$proPath = $tempDir.'/'.$proFilename;
$this->writeProFile($proPath, $label, $groups, $arrangements);
$embeddedFiles[$proFilename] = file_get_contents($proPath);
$playlistItems[] = [
'type' => 'presentation',
'name' => $label,
'path' => $proFilename,
];
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
}
private function buildModeratorSlideData(Service $service): ?array
{
$name = app(NameTagResolver::class)->moderatorFor($service);
if ($name === null) {
return null;
}
return app(NameTagSlideBuilder::class)->buildModeratorSlide($name);
}
private function buildPreacherSlideData(Service $service): ?array
{
$name = app(NameTagResolver::class)->preacherFor($service);
if ($name === null) {
return null;
}
return app(NameTagSlideBuilder::class)->buildPreacherSlide($name);
}
private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void
{
$kvData = $this->keyVisualData($service);
if ($kvData === null) {
return;
}
$this->embedKeyVisual($service, $embeddedFiles);
$slideData = ['imageOnly' => true, 'background' => $kvData];
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
}
private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
{
$slideData = $this->buildPreacherSlideData($service);
if ($slideData === null) {
return;
}
$this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles);
}
private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
{
$groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]];
$arrangements = [['name' => 'normal', 'groupNames' => [$name]]];
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro';
$path = $tempDir.'/'.$filename;
$this->writeProFile($path, $name, $groups, $arrangements);
$embeddedFiles[$filename] = file_get_contents($path);
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
}
private function countSongLabels(\App\Models\Song $song): int
{
return $song->arrangements()
@ -378,6 +538,140 @@ private function countSongLabels(\App\Models\Song $song): int
->sum('arrangement_labels_count');
}
private function backgroundData(?Service $service): ?array
{
if ($service === null) {
return null;
}
if ($this->backgroundSourcePath($service) === null) {
return null;
}
return [
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
private function keyVisualData(Service $service): ?array
{
if ($this->keyVisualSourcePath($service) === null) {
return null;
}
return [
'path' => ServiceImageResolver::KEY_VISUAL_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
/** Absolute filesystem path of the resolved background image, or null. */
private function backgroundSourcePath(?Service $service): ?string
{
if ($service === null) {
return null;
}
$background = app(ServiceImageResolver::class)->backgroundFor($service);
if ($background === null || ! Storage::disk('public')->exists($background)) {
return null;
}
return Storage::disk('public')->path($background);
}
/** Absolute filesystem path of the resolved key-visual image, or null. */
private function keyVisualSourcePath(Service $service): ?string
{
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
if ($keyVisual === null || ! Storage::disk('public')->exists($keyVisual)) {
return null;
}
return Storage::disk('public')->path($keyVisual);
}
/** Embed the resolved background image bytes into the archive under the fixed export name. */
private function embedBackground(?Service $service, array &$embeddedFiles): void
{
$sourcePath = $this->backgroundSourcePath($service);
if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
return;
}
$contents = @file_get_contents($sourcePath);
if ($contents !== false) {
$embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
}
}
/** Embed the resolved key-visual image bytes into the archive under the fixed export name. */
private function embedKeyVisual(Service $service, array &$embeddedFiles): void
{
$sourcePath = $this->keyVisualSourcePath($service);
if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME])) {
return;
}
$contents = @file_get_contents($sourcePath);
if ($contents !== false) {
$embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME] = $contents;
}
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{
if ($background === null) {
return false;
}
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
return false;
}
return $slide->cover_mode !== true;
}
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): string
{
if ($item->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
return 'sermon';
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns === null) {
return 'agenda_item';
}
$patterns = array_map('trim', explode(',', $sermonPatterns));
return app(AgendaMatcherService::class)->matchesAny($item->title, $patterns)
? 'sermon'
: 'agenda_item';
}
private function isNameTagAgendaItem(ServiceAgendaItem $item): bool
{
$title = mb_strtolower($item->title ?? '');
$type = mb_strtolower($item->type ?? '');
return str_contains($title, 'nametag')
|| str_contains($title, 'namenseinblender')
|| str_contains($type, 'nametag')
|| str_contains($type, 'namenseinblender');
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -4,6 +4,8 @@
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Models\Slide;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use ProPresenter\Parser\PresentationBundle;
@ -17,6 +19,7 @@ class ProBundleExportService
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
) {}
public function generateBundle(Service $service, string $blockType): string
@ -32,7 +35,7 @@ public function generateBundle(Service $service, string $blockType): string
$groupName = ucfirst($blockType);
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType);
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
}
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
@ -40,7 +43,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
$agendaItem->loadMissing([
'service',
'slides',
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.song.arrangements.arrangementSections.section.label',
]);
$title = $agendaItem->title ?: 'Ablauf-Element';
@ -60,7 +64,10 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
$proFilename = self::safeFilename($song->title).'.pro';
$bundle = new PresentationBundle($parserSong, $proFilename);
$songMediaFiles = [];
$this->embedBackground($agendaItem->service, $songMediaFiles);
$bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles);
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
ProBundleWriter::write($bundle, $bundlePath);
@ -72,14 +79,27 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
->orderBy('sort_order')
->get();
return $this->buildBundleFromSlides($slides, $title, $agendaItem->service, 'agenda_item');
return $this->buildBundleFromSlides(
$slides,
$title,
$agendaItem->service,
'agenda_item',
$this->backgroundPartTypeForAgendaItem($agendaItem),
);
}
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
private function buildBundleFromSlides($slides, string $groupName, ?Service $service = null, ?string $partType = null): string
{
private function buildBundleFromSlides(
$slides,
string $groupName,
?Service $service = null,
?string $partType = null,
?string $backgroundPartType = null,
): string {
$slideData = [];
$mediaFiles = [];
$background = $this->backgroundData($service);
$backgroundAttached = false;
foreach ($slides as $slide) {
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
@ -101,6 +121,11 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
'label' => $slide->original_filename,
];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background;
$backgroundAttached = true;
}
if ($service !== null && $partType !== null) {
$slideIndex = count($slideData);
$totalSlides = $slides->count();
@ -119,6 +144,10 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
$slideData[] = $singleSlideData;
}
if ($backgroundAttached) {
$this->embedBackground($service, $mediaFiles);
}
$groups = [
[
'name' => $groupName,
@ -144,6 +173,83 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
return $bundlePath;
}
private function backgroundData(?Service $service): ?array
{
if ($this->backgroundSourcePath($service) === null) {
return null;
}
return [
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
/** Absolute filesystem path of the resolved background image, or null. */
private function backgroundSourcePath(?Service $service): ?string
{
if ($service === null) {
return null;
}
$background = $this->imageResolver->backgroundFor($service);
if ($background === null || ! Storage::disk('public')->exists($background)) {
return null;
}
return Storage::disk('public')->path($background);
}
/** Embed the resolved background image bytes into the bundle under the fixed export name. */
private function embedBackground(?Service $service, array &$mediaFiles): void
{
$sourcePath = $this->backgroundSourcePath($service);
if ($sourcePath === null || isset($mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
return;
}
$contents = @file_get_contents($sourcePath);
if ($contents !== false) {
$mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
}
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{
if ($background === null) {
return false;
}
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
return false;
}
return $slide->cover_mode !== true;
}
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $agendaItem): string
{
if ($agendaItem->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
return 'sermon';
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns === null) {
return 'agenda_item';
}
$patterns = array_map('trim', explode(',', $sermonPatterns));
return app(AgendaMatcherService::class)->matchesAny($agendaItem->title, $patterns)
? 'sermon'
: 'agenda_item';
}
private static function safeFilename(string $name): string
{
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';

View file

@ -10,6 +10,7 @@ class ProExportService
{
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
) {}
public function generateProFile(Song $song, ?Service $service = null): string
@ -29,7 +30,7 @@ public function generateProFile(Song $song, ?Service $service = null): string
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
{
$song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
return ProFileGenerator::generate(
$song->title,
@ -47,34 +48,40 @@ private function buildGroups(Song $song, ?Service $service = null): array
return [];
}
$defaultArr->loadMissing('arrangementLabels.label.songSlides');
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
$groups = [];
$seenLabelIds = [];
$seenSectionIds = [];
$background = $this->backgroundData($service);
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
$label = $section?->label;
if ($label === null) {
if ($section === null || $label === null) {
continue;
}
if (in_array($label->id, $seenLabelIds, true)) {
if (in_array($section->id, $seenSectionIds, true)) {
continue;
}
$seenLabelIds[] = $label->id;
$seenSectionIds[] = $section->id;
$slides = [];
$labelSlides = $label->songSlides->sortBy('order')->values();
$totalSlides = $labelSlides->count();
$sectionSlides = $section->slides->sortBy('order')->values();
$totalSlides = $sectionSlides->count();
foreach ($labelSlides as $slideIndex => $slide) {
foreach ($sectionSlides as $slideIndex => $slide) {
$slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) {
$slideData['translation'] = $slide->text_content_translated;
}
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
$slideData['background'] = $background;
}
if ($service !== null) {
$macros = $this->macroResolutionService->macrosForSlide(
$service,
@ -101,16 +108,46 @@ private function buildGroups(Song $song, ?Service $service = null): array
return $groups;
}
private function backgroundData(?Service $service): ?array
{
if ($service === null) {
return null;
}
$background = $this->imageResolver->backgroundFor($service);
if ($background === null) {
return null;
}
return [
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
private function isFullCoverImageSlide(object $slide, array $slideData): bool
{
if (! isset($slideData['media'])) {
return false;
}
return ($slide->cover_mode ?? null) === true;
}
private function buildArrangements(Song $song): array
{
$arrangements = [];
foreach ($song->arrangements as $arrangement) {
$arrangement->loadMissing('arrangementLabels.label');
$arrangement->loadMissing('arrangementSections.section.label');
$groupNames = $arrangement->arrangementLabels
$groupNames = $arrangement->arrangementSections
->sortBy('order')
->map(fn ($al) => $al->label?->name)
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
->filter()
->values()
->toArray();

View file

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

View file

@ -0,0 +1,47 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\Setting;
use Illuminate\Support\Facades\Storage;
class ServiceImageResolver
{
/**
* Fixed export filename for the key-visual image. The image bytes are
* embedded under this name AND referenced by this name inside the .pro file.
*/
public const KEY_VISUAL_EXPORT_NAME = 'KEY_VISUAL.jpg';
/**
* Fixed export filename for the background image. The image bytes are
* embedded under this name AND referenced by this name inside the .pro file.
*/
public const BACKGROUND_EXPORT_NAME = 'BACKGROUND.jpg';
public function keyVisualFor(Service $service): ?string
{
return $this->resolve($service->key_visual_filename, 'current_key_visual');
}
public function backgroundFor(Service $service): ?string
{
return $this->resolve($service->background_filename, 'current_background');
}
private function resolve(?string $serviceFilename, string $settingKey): ?string
{
if ($serviceFilename !== null && Storage::disk('public')->exists($serviceFilename)) {
return $serviceFilename;
}
$globalFilename = Setting::get($settingKey);
if ($globalFilename !== null && Storage::disk('public')->exists($globalFilename)) {
return $globalFilename;
}
return null;
}
}

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,30 @@
<?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('services', function (Blueprint $table) {
$table->string('key_visual_filename')->nullable()->after('beamer_tech_name');
$table->string('background_filename')->nullable()->after('key_visual_filename');
$table->string('moderator_name')->nullable()->after('background_filename');
$table->string('preacher_name_override')->nullable()->after('moderator_name');
});
}
public function down(): void
{
Schema::table('services', function (Blueprint $table) {
$table->dropColumn([
'key_visual_filename',
'background_filename',
'moderator_name',
'preacher_name_override',
]);
});
}
};

View file

@ -0,0 +1,22 @@
<?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('slides', function (Blueprint $table) {
$table->boolean('cover_mode')->nullable()->after('thumbnail_filename');
});
}
public function down(): void
{
Schema::table('slides', function (Blueprint $table) {
$table->dropColumn('cover_mode');
});
}
};

View file

@ -0,0 +1,246 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('song_sections', function (Blueprint $table): void {
$table->id();
$table->foreignId('song_id')->constrained()->cascadeOnDelete();
$table->foreignId('label_id')->constrained('labels')->restrictOnDelete();
$table->unsignedInteger('order')->default(0);
$table->timestamps();
$table->unique(['song_id', 'label_id']);
});
Schema::table('song_slides', function (Blueprint $table): void {
$table->foreignId('song_section_id')->nullable()->after('id')->constrained('song_sections')->cascadeOnDelete();
});
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
$table->foreignId('song_section_id')->nullable()->after('label_id')->constrained('song_sections')->cascadeOnDelete();
});
$this->backfillSections();
$this->backfillSlides();
$this->backfillArrangementSections();
DB::table('song_slides')->whereNull('song_section_id')->delete();
if (DB::getDriverName() === 'sqlite') {
$this->finalizeSqliteTables();
return;
}
if (DB::getDriverName() !== 'sqlite') {
Schema::table('song_slides', function (Blueprint $table): void {
$table->dropForeign(['label_id']);
});
}
Schema::table('song_slides', function (Blueprint $table): void {
$table->dropColumn('label_id');
});
Schema::table('song_slides', function (Blueprint $table): void {
$table->foreignId('song_section_id')->nullable(false)->change();
});
if (DB::getDriverName() !== 'sqlite') {
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
$table->dropForeign(['label_id']);
});
}
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
$table->dropColumn('label_id');
});
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
$table->foreignId('song_section_id')->nullable(false)->change();
});
}
public function down(): void
{
throw new \RuntimeException('Destruktive Migration: kein Rollback. Backup einspielen.');
}
private function backfillSections(): void
{
DB::table('song_arrangements')
->select('song_arrangements.song_id')
->join('song_arrangement_labels', 'song_arrangement_labels.song_arrangement_id', '=', 'song_arrangements.id')
->whereNotNull('song_arrangement_labels.label_id')
->distinct()
->orderBy('song_arrangements.song_id')
->chunk(100, function ($songs): void {
foreach ($songs as $song) {
$this->backfillSectionsForSong((int) $song->song_id);
}
});
}
private function backfillSectionsForSong(int $songId): void
{
$labelIds = DB::table('song_arrangement_labels')
->join('song_arrangements', 'song_arrangements.id', '=', 'song_arrangement_labels.song_arrangement_id')
->where('song_arrangements.song_id', $songId)
->whereNotNull('song_arrangement_labels.label_id')
->distinct()
->orderBy('song_arrangement_labels.label_id')
->pluck('song_arrangement_labels.label_id')
->map(fn ($labelId): int => (int) $labelId)
->all();
if ($labelIds === []) {
return;
}
$preferredArrangementId = DB::table('song_arrangements')
->where('song_id', $songId)
->orderByDesc('is_default')
->orderByRaw("lower(name) = 'normal' desc")
->orderBy('id')
->value('id');
$preferredOrders = collect();
if ($preferredArrangementId !== null) {
$preferredOrders = DB::table('song_arrangement_labels')
->select('label_id', DB::raw('min("order") as section_order'))
->where('song_arrangement_id', $preferredArrangementId)
->whereIn('label_id', $labelIds)
->groupBy('label_id')
->pluck('section_order', 'label_id');
}
$fallbackOrder = ((int) $preferredOrders->max()) + 1;
$now = now();
$rows = [];
foreach ($labelIds as $labelId) {
$rows[] = [
'song_id' => $songId,
'label_id' => $labelId,
'order' => $preferredOrders->has($labelId) ? (int) $preferredOrders->get($labelId) : $fallbackOrder++,
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('song_sections')->insertOrIgnore($rows);
}
private function backfillSlides(): void
{
DB::table('song_sections')
->select(['id', 'label_id'])
->orderBy('id')
->chunkById(100, function ($sections): void {
foreach ($sections as $section) {
$this->backfillSlidesForSection((int) $section->id, (int) $section->label_id);
}
});
}
private function backfillSlidesForSection(int $sectionId, int $labelId): void
{
DB::table('song_slides')
->where('label_id', $labelId)
->whereNull('song_section_id')
->orderBy('order')
->chunkById(200, function ($slides) use ($sectionId): void {
$now = now();
$rows = [];
foreach ($slides as $slide) {
$rows[] = [
'label_id' => $slide->label_id,
'song_section_id' => $sectionId,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
'notes' => $slide->notes,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($rows !== []) {
DB::table('song_slides')->insert($rows);
}
});
}
private function backfillArrangementSections(): void
{
DB::table('song_arrangement_labels')
->select([
'song_arrangement_labels.id',
'song_arrangement_labels.label_id',
'song_arrangements.song_id',
])
->join('song_arrangements', 'song_arrangements.id', '=', 'song_arrangement_labels.song_arrangement_id')
->whereNotNull('song_arrangement_labels.label_id')
->orderBy('song_arrangement_labels.id')
->chunkById(500, function ($arrangementLabels): void {
foreach ($arrangementLabels as $arrangementLabel) {
$sectionId = DB::table('song_sections')
->where('song_id', $arrangementLabel->song_id)
->where('label_id', $arrangementLabel->label_id)
->value('id');
if ($sectionId !== null) {
DB::table('song_arrangement_labels')
->where('id', $arrangementLabel->id)
->update(['song_section_id' => $sectionId]);
}
}
}, 'song_arrangement_labels.id', 'id');
}
private function finalizeSqliteTables(): void
{
Schema::disableForeignKeyConstraints();
Schema::create('song_slides_new', function (Blueprint $table): void {
$table->id();
$table->foreignId('song_section_id')->constrained('song_sections')->cascadeOnDelete();
$table->unsignedInteger('order');
$table->text('text_content');
$table->text('text_content_translated')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
DB::statement('insert into song_slides_new (id, song_section_id, "order", text_content, text_content_translated, notes, created_at, updated_at) select id, song_section_id, "order", text_content, text_content_translated, notes, created_at, updated_at from song_slides');
Schema::drop('song_slides');
Schema::rename('song_slides_new', 'song_slides');
Schema::create('song_arrangement_labels_new', function (Blueprint $table): void {
$table->id();
$table->foreignId('song_arrangement_id')->constrained()->cascadeOnDelete();
$table->foreignId('song_section_id')->constrained('song_sections')->cascadeOnDelete();
$table->unsignedInteger('order');
$table->timestamps();
$table->index(['song_arrangement_id', 'order']);
});
DB::statement('insert into song_arrangement_labels_new (id, song_arrangement_id, song_section_id, "order", created_at, updated_at) select id, song_arrangement_id, song_section_id, "order", created_at, updated_at from song_arrangement_labels');
Schema::drop('song_arrangement_labels');
Schema::rename('song_arrangement_labels_new', 'song_arrangement_labels');
Schema::enableForeignKeyConstraints();
}
};

520
package-lock.json generated
View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
import SongEditModal from '@/Components/SongEditModal.vue'
const MASTER_ID = 'master'
@ -27,6 +28,14 @@ const props = defineProps({
type: Number,
default: null,
},
serviceSongName: {
type: String,
default: '',
},
serviceSongCcliId: {
type: String,
default: '',
},
songsCatalog: {
type: Array,
default: () => [],
@ -39,11 +48,12 @@ const emit = defineEmits(['close', 'arrangement-selected'])
const isUnmatched = computed(() => !props.songId)
const searchQuery = ref('')
const searchQuery = ref(props.serviceSongName ?? '')
const selectedSongId = ref('')
const dropdownOpen = ref(false)
const assignError = ref('')
const ccliDialogOpen = ref(false)
const editSongId = ref(null)
function normalize(value) {
return (value ?? '').toString().toLowerCase().trim()
@ -57,6 +67,23 @@ const filteredCatalog = computed(() => {
.slice(0, 100)
})
// Build the SongSelect URL: prefer the CCLI number (direct song page), else search by name.
const songSelectUrl = computed(() => {
const ccli = (props.serviceSongCcliId ?? '').toString().trim()
if (ccli !== '') {
return `https://songselect.ccli.com/songs/${encodeURIComponent(ccli)}`
}
const query = (searchQuery.value || props.serviceSongName || '').trim()
return `https://songselect.ccli.com/search/results?search=${encodeURIComponent(query)}`
})
// Open SongSelect in a new tab AND open the CCLI import dialog so the user can paste.
function openSongSelect() {
window.open(songSelectUrl.value, '_blank', 'noopener,noreferrer')
ccliDialogOpen.value = true
}
function openSearchDropdown() {
dropdownOpen.value = true
}
@ -248,6 +275,11 @@ function closeOnEscape(e) {
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick)
// For unmatched songs: show search results immediately (prefilled with the song name).
if (isUnmatched.value) {
dropdownOpen.value = true
}
})
onUnmounted(() => {
@ -530,10 +562,19 @@ function closeOnBackdrop(e) {
v-for="song in filteredCatalog"
:key="song.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-emerald-50"
data-testid="song-search-option"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
:class="song.has_content === false ? 'bg-orange-50 hover:bg-orange-100' : 'hover:bg-emerald-50'"
@mousedown.prevent="selectSong(song)"
>
<span class="font-medium text-gray-900">{{ song.title }}</span>
<span
v-if="song.has_content === false"
data-testid="song-search-no-content"
class="inline-flex items-center rounded-full bg-orange-100 px-1.5 py-0.5 text-[10px] font-semibold text-orange-700"
>
Ohne Inhalt
</span>
<span class="ml-auto text-xs text-gray-400">CCLI: {{ song.ccli_id || '' }}</span>
</button>
</div>
@ -550,16 +591,14 @@ function closeOnBackdrop(e) {
>
Zuordnen
</button>
<a
v-if="searchQuery"
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(searchQuery)"
target="_blank"
rel="noopener noreferrer"
<button
type="button"
data-testid="songselect-search-button"
@click="openSongSelect"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
>
Auf SongSelect suchen
</a>
</button>
<button
type="button"
data-testid="open-ccli-paste-dialog-button"
@ -765,6 +804,15 @@ function closeOnBackdrop(e) {
:service-song-id="props.serviceSongId"
@close="ccliDialogOpen = false"
@imported="(songId) => { ccliDialogOpen = false; router.reload({ only: ['service'] }) }"
@edit-song="(id) => { ccliDialogOpen = false; editSongId = id }"
/>
<!-- Song Edit Modal -->
<SongEditModal
:show="editSongId !== null"
:song-id="editSongId"
@close="editSongId = null"
@updated="() => router.reload({ only: ['service'] })"
/>
</template>

View file

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

View file

@ -0,0 +1,142 @@
<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps({
label: { type: String, required: true },
currentUrl: { type: String, default: null },
uploadRoute: { type: String, required: true },
serviceId: { type: Number, required: true },
sourceName: { type: String, default: null },
testid: { type: String, default: null },
})
const showScopeDialog = ref(false)
const selectedFile = ref(null)
const uploading = ref(false)
const error = ref(null)
const fileInput = ref(null)
function onFileChange(e) {
const file = e.target.files[0]
if (!file) return
selectedFile.value = file
showScopeDialog.value = true
}
function cancelDialog() {
showScopeDialog.value = false
selectedFile.value = null
if (fileInput.value) fileInput.value.value = ''
}
async function uploadWithScope(scope) {
if (!selectedFile.value) return
uploading.value = true
showScopeDialog.value = false
error.value = null
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('scope', scope)
const xsrfCookie = document.cookie.split('; ').find(r => r.startsWith('XSRF-TOKEN='))
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.split('=')[1]) : ''
try {
const url = route(props.uploadRoute, { service: props.serviceId })
const response = await fetch(url, {
method: 'POST',
headers: { 'X-XSRF-TOKEN': xsrfToken },
body: formData,
})
if (response.ok || response.status === 302) {
router.reload({ preserveScroll: true })
} else {
const data = await response.json().catch(() => ({}))
error.value = data.message || 'Upload fehlgeschlagen.'
}
} catch {
error.value = 'Upload fehlgeschlagen.'
} finally {
uploading.value = false
selectedFile.value = null
if (fileInput.value) fileInput.value.value = ''
}
}
</script>
<template>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm" :data-testid="testid">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700">{{ label }}</h3>
<span v-if="sourceName" class="text-xs text-gray-400">{{ sourceName }}</span>
</div>
<div class="mb-3">
<img
v-if="currentUrl"
:src="currentUrl"
class="h-24 w-full rounded object-cover"
:data-testid="testid ? testid + '-thumb' : undefined"
:alt="label"
/>
<div v-else class="flex h-24 w-full items-center justify-center rounded bg-gray-100 text-gray-400">
<span class="text-sm">Kein Bild hinterlegt</span>
</div>
</div>
<p v-if="error" class="mb-2 text-xs text-red-600">{{ error }}</p>
<label class="cursor-pointer">
<span
class="inline-flex items-center rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
:class="{ 'cursor-not-allowed opacity-50': uploading }"
>
{{ uploading ? 'Lädt hoch…' : currentUrl ? 'Ersetzen' : 'Hochladen' }}
</span>
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/jpg"
class="hidden"
:disabled="uploading"
:data-testid="testid ? testid + '-upload-input' : undefined"
@change="onFileChange"
/>
</label>
<!-- Scope dialog -->
<div
v-if="showScopeDialog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
data-testid="scope-dialog"
>
<div class="mx-4 w-full max-w-sm rounded-lg bg-white p-6 shadow-xl">
<h4 class="mb-3 text-base font-semibold text-gray-900">Geltungsbereich wählen</h4>
<p class="mb-4 text-sm text-gray-600">
Soll dieses Bild nur für diesen Service gelten oder als Standard für alle zukünftigen Services gesetzt werden?
</p>
<div class="flex flex-col gap-2">
<button
class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
data-testid="scope-service"
@click="uploadWithScope('service')"
>
Nur für diesen Service
</button>
<button
class="rounded border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
data-testid="scope-default"
@click="uploadWithScope('default')"
>
Als Standard setzen (gilt bis zum nächsten Upload)
</button>
<button
class="mt-1 text-xs text-gray-400 hover:text-gray-600"
@click="cancelDialog"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
</template>

View file

@ -21,17 +21,78 @@ const emit = defineEmits(['close', 'updated'])
const loading = ref(false)
const error = ref(null)
const songData = ref(null)
const sectionDrafts = ref({})
const title = ref('')
const ccliId = ref('')
const copyrightText = ref('')
const showAddSectionForm = ref(false)
const newSectionLabel = ref('')
const newSectionText = ref('')
const sectionLabelDropdownOpen = ref(false)
const saving = ref(false)
const saved = ref(false)
let savedTimeout = null
const sectionSaveDebouncers = new Map()
/* ── Save status ── */
function startSaving() {
saving.value = true
saved.value = false
}
function finishSaving() {
saving.value = false
saved.value = true
if (savedTimeout) clearTimeout(savedTimeout)
savedTimeout = setTimeout(() => {
saved.value = false
}, 2000)
}
function stopSaving() {
saving.value = false
}
/* ── Data fetching ── */
function slidesToText(slides = [], key) {
return slides
.map((slide) => slide[key] ?? '')
.filter((text) => text !== '')
.join('\n\n')
}
function sectionKey(group) {
return group.section_id ?? group.id
}
function setSectionDrafts(data) {
const drafts = {}
;(data?.groups ?? []).forEach((group) => {
drafts[sectionKey(group)] = {
text: slidesToText(group.slides, 'text_content'),
translated: slidesToText(group.slides, 'text_content_translated'),
}
})
sectionDrafts.value = drafts
}
function draftFor(group) {
const key = sectionKey(group)
if (!sectionDrafts.value[key]) {
sectionDrafts.value[key] = { text: '', translated: '' }
}
return sectionDrafts.value[key]
}
const fetchSong = async () => {
if (!props.songId) return
@ -53,6 +114,7 @@ const fetchSong = async () => {
const json = await response.json()
songData.value = json.data
setSectionDrafts(json.data)
title.value = json.data.title ?? ''
ccliId.value = json.data.ccli_id ?? ''
@ -73,7 +135,11 @@ watch(
if (!isVisible) {
songData.value = null
sectionDrafts.value = {}
error.value = null
showAddSectionForm.value = false
newSectionLabel.value = ''
newSectionText.value = ''
}
},
)
@ -83,8 +149,7 @@ watch(
const performSave = async (data) => {
if (!props.songId) return
saving.value = true
saved.value = false
startSaving()
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
@ -105,17 +170,11 @@ const performSave = async (data) => {
throw new Error('Speichern fehlgeschlagen')
}
saving.value = false
saved.value = true
if (savedTimeout) clearTimeout(savedTimeout)
savedTimeout = setTimeout(() => {
saved.value = false
}, 2000)
finishSaving()
emit('updated')
} catch {
saving.value = false
stopSaving()
}
}
@ -150,10 +209,12 @@ const arrangements = computed(() => {
name: arr.name,
is_default: arr.is_default,
groups: arr.arrangement_groups.map((ag) => {
const group = songData.value.groups.find((g) => g.id === ag.label_id)
const group = songData.value.groups.find((g) => g.id === (ag.section_id ?? ag.label_id))
return {
id: ag.label_id,
id: ag.section_id ?? ag.label_id,
section_id: ag.section_id ?? ag.label_id,
label_id: ag.label_id,
name: group?.name ?? 'Unbekannt',
color: group?.color ?? '#6b7280',
order: ag.order,
@ -167,11 +228,191 @@ const availableGroups = computed(() => {
return songData.value.groups.map((group) => ({
id: group.id,
section_id: group.section_id ?? group.id,
label_id: group.label_id,
name: group.name,
color: group.color,
}))
})
const sectionLabelOptions = computed(() => {
const fromLabels = (songData.value?.available_labels ?? []).map((label) => label.name)
const fromGroups = (songData.value?.groups ?? []).map((group) => group.name)
return [...new Set([...fromLabels, ...fromGroups].filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'))
})
const filteredSectionLabelOptions = computed(() => {
const term = newSectionLabel.value.trim().toLowerCase()
if (term === '') return sectionLabelOptions.value
return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term))
})
const canCreateNewLabel = computed(() => {
const term = newSectionLabel.value.trim()
if (term === '') return false
return !sectionLabelOptions.value.some((name) => name.toLowerCase() === term.toLowerCase())
})
function selectSectionLabel(name) {
newSectionLabel.value = name
sectionLabelDropdownOpen.value = false
}
function openSectionLabelDropdown() {
sectionLabelDropdownOpen.value = true
}
function closeSectionLabelDropdown() {
setTimeout(() => {
sectionLabelDropdownOpen.value = false
}, 150)
}
/* ── Section editing ── */
function splitSectionText(value) {
const trimmed = (value ?? '').replace(/\r\n/g, '\n').replace(/\s+$/u, '')
const blocks = trimmed
.split(/\n\s*\n+/u)
.map((block) => block.trim())
.filter((block) => block !== '')
return blocks.length ? blocks : ['']
}
function buildSectionSlides(sectionId) {
const draft = sectionDrafts.value[sectionId] ?? { text: '', translated: '' }
const textBlocks = splitSectionText(draft.text)
const translatedBlocks = (draft.translated ?? '').trim() === '' ? [] : splitSectionText(draft.translated)
return textBlocks.map((text, index) => ({
text_content: text,
text_content_translated: translatedBlocks[index] ?? null,
}))
}
async function saveSection(sectionId) {
if (!props.songId || !sectionDrafts.value[sectionId]) return
startSaving()
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
slides: buildSectionSlides(sectionId),
}),
})
if (!response.ok) {
throw new Error('Sektion konnte nicht gespeichert werden.')
}
const json = await response.json()
songData.value = json.data
setSectionDrafts(json.data)
finishSaving()
emit('updated')
} catch {
stopSaving()
}
}
function onSectionInput(sectionId) {
if (!sectionSaveDebouncers.has(sectionId)) {
sectionSaveDebouncers.set(sectionId, useDebounceFn(() => {
saveSection(sectionId)
}, 600))
}
sectionSaveDebouncers.get(sectionId)()
}
function onSectionBlur(sectionId) {
sectionSaveDebouncers.get(sectionId)?.cancel?.()
saveSection(sectionId)
}
async function deleteSection(sectionId) {
if (!window.confirm('Möchtest Du diese Sektion wirklich löschen?')) return
startSaving()
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), {
method: 'DELETE',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Sektion konnte nicht gelöscht werden.')
}
const json = await response.json()
songData.value = json.data
setSectionDrafts(json.data)
finishSaving()
emit('updated')
} catch {
stopSaving()
}
}
async function addSection() {
if (!props.songId || !newSectionLabel.value.trim()) return
startSaving()
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const response = await fetch(route('songs.sections.store', props.songId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
label_name: newSectionLabel.value,
slides: splitSectionText(newSectionText.value).map((text) => ({ text_content: text })),
}),
})
if (!response.ok) {
throw new Error('Sektion konnte nicht hinzugefügt werden.')
}
newSectionLabel.value = ''
newSectionText.value = ''
showAddSectionForm.value = false
await fetchSong()
finishSaving()
emit('updated')
} catch {
stopSaving()
}
}
/* ── Close handling ── */
const closeOnEscape = (e) => {
@ -481,6 +722,177 @@ onUnmounted(() => {
</div>
</div>
<!-- Section editing -->
<div class="border-b border-gray-100 px-6 py-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Sektionen
</h3>
<p class="mt-1 text-sm text-gray-500">
Leerzeilen trennen einzelne Folien. Änderungen speichern automatisch.
</p>
</div>
<button
data-testid="section-add-button"
type="button"
class="rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600"
@click="showAddSectionForm = !showAddSectionForm"
>
Neue Sektion
</button>
</div>
<form
v-if="showAddSectionForm"
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4"
@submit.prevent="addSection"
>
<div class="grid gap-4 sm:grid-cols-3">
<div class="relative">
<label for="section-add-label" class="mb-1 block text-sm font-medium text-gray-700">
Name
</label>
<input
data-testid="section-add-label-input"
id="section-add-label"
v-model="newSectionLabel"
type="text"
autocomplete="off"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Abschnitt wählen oder neuen erstellen…"
required
@focus="openSectionLabelDropdown"
@input="openSectionLabelDropdown"
@blur="closeSectionLabelDropdown"
>
<!-- Combobox dropdown -->
<div
v-if="sectionLabelDropdownOpen && (filteredSectionLabelOptions.length > 0 || canCreateNewLabel)"
data-testid="section-label-dropdown"
class="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="labelName in filteredSectionLabelOptions"
:key="labelName"
type="button"
data-testid="section-label-option"
class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50"
@mousedown.prevent="selectSectionLabel(labelName)"
>
{{ labelName }}
</button>
<button
v-if="canCreateNewLabel"
type="button"
data-testid="section-label-create-option"
class="block w-full border-t border-gray-100 px-3 py-2 text-left text-sm font-medium text-amber-700 hover:bg-amber-50"
@mousedown.prevent="selectSectionLabel(newSectionLabel.trim())"
>
Neu erstellen: {{ newSectionLabel.trim() }}"
</button>
</div>
</div>
<div class="sm:col-span-2">
<label for="section-add-text" class="mb-1 block text-sm font-medium text-gray-700">
Text
</label>
<textarea
data-testid="section-add-text-input"
id="section-add-text"
v-model="newSectionText"
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext eingeben…"
/>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-white/70"
@click="showAddSectionForm = false"
>
Abbrechen
</button>
<button
data-testid="section-add-submit"
type="submit"
class="rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-gray-800"
>
Hinzufügen
</button>
</div>
</form>
<div class="space-y-4">
<div
v-for="group in songData.groups"
:key="group.section_id ?? group.id"
data-testid="section-block"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
>
<div class="mb-3 flex items-center justify-between gap-3">
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white shadow-sm"
:style="{ backgroundColor: group.color ?? '#6b7280' }"
>
{{ group.name }}
</span>
<button
data-testid="section-delete-button"
type="button"
class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700"
title="Sektion löschen"
@click="deleteSection(group.section_id ?? group.id)"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7h6m2 0H7m3-3h4a1 1 0 011 1v2H9V5a1 1 0 011-1z" />
</svg>
</button>
</div>
<div :class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700">
Originaltext
</label>
<textarea
data-testid="section-text-input"
v-model="draftFor(group).text"
rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext…"
@input="onSectionInput(group.section_id ?? group.id)"
@blur="onSectionBlur(group.section_id ?? group.id)"
/>
</div>
<div v-if="songData.has_translation">
<label class="mb-1 block text-sm font-medium text-gray-700">
Übersetzung
</label>
<textarea
data-testid="section-translated-input"
v-model="draftFor(group).translated"
rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Übersetzter Folientext…"
@input="onSectionInput(group.section_id ?? group.id)"
@blur="onSectionBlur(group.section_id ?? group.id)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Arrangement Configurator -->
<div class="px-6 py-5">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">

View file

@ -1,6 +1,8 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, router } from '@inertiajs/vue3'
import { Head, router, usePage } from '@inertiajs/vue3'
const $page = usePage()
import { ref, computed } from 'vue'
import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
import AgendaItemRow from '@/Components/AgendaItemRow.vue'
@ -8,6 +10,7 @@ import SongAgendaItem from '@/Components/SongAgendaItem.vue'
import ArrangementDialog from '@/Components/ArrangementDialog.vue'
import MacroIcon from '@/Components/MacroIcon.vue'
import ServicePartMacroPanel from '@/Components/ServicePartMacroPanel.vue'
import ServiceImagePanel from '@/Components/ServiceImagePanel.vue'
const props = defineProps({
service: {
@ -95,6 +98,12 @@ function refreshPage() {
router.reload({ preserveScroll: true })
}
function updateNameOverride(field, value) {
router.patch(route('services.name-overrides.update', { service: props.service.id }), {
[field]: value || null,
}, { preserveScroll: true })
}
function scrollToInfoBlock() {
informationBlockRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
@ -379,6 +388,66 @@ async function downloadService() {
</div>
</template>
<!-- Keyvisual & Hintergrundbild -->
<div class="py-4">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ServiceImagePanel
label="Keyvisual"
:current-url="service.key_visual_url"
upload-route="services.key-visual.store"
:service-id="service.id"
:source-name="service.key_visual_is_own ? 'Eigenes Bild' : (service.key_visual_filename ? 'Standard' : null)"
testid="keyvisual-panel"
/>
<ServiceImagePanel
label="Hintergrundbild"
:current-url="service.background_url"
upload-route="services.background.store"
:service-id="service.id"
:source-name="service.background_is_own ? 'Eigenes Bild' : (service.background_filename ? 'Standard' : null)"
testid="background-panel"
/>
</div>
</div>
</div>
<!-- Name-Überschreibungen (Moderation & Predigt) -->
<div class="pb-4">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<label class="mb-1 block text-sm font-semibold text-gray-700" for="moderator_name">
Moderation Name (Überschreiben)
</label>
<input
id="moderator_name"
type="text"
:value="service.moderator_name ?? ''"
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none"
placeholder="Aus dem Ablauf ermittelt"
data-testid="moderator-name-override"
@change="updateNameOverride('moderator_name', $event.target.value)"
/>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<label class="mb-1 block text-sm font-semibold text-gray-700" for="preacher_name_override">
Predigt Name (Überschreiben)
</label>
<input
id="preacher_name_override"
type="text"
:value="service.preacher_name_override ?? ''"
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none"
placeholder="Aus ChurchTools übernommen"
data-testid="preacher-name-override"
@change="updateNameOverride('preacher_name_override', $event.target.value)"
/>
</div>
</div>
</div>
</div>
<!-- Ablauf (Agenda) -->
<div class="py-6">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
@ -515,6 +584,8 @@ async function downloadService() {
:available-groups="getAvailableGroups(arrangementDialogItem)"
:selected-arrangement-id="getSelectedArrangementId(arrangementDialogItem)"
:service-song-id="arrangementDialogItem.service_song?.id ?? arrangementDialogItem.serviceSong?.id"
: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) ?? '')"
:songs-catalog="songsCatalog"
@close="onArrangementDialogClosed"
/>

View file

@ -23,6 +23,7 @@ const submenus = [
{ key: 'labels', label: 'Label-Import' },
{ key: 'agenda', label: 'Agenda' },
{ key: 'ccli', label: 'CCLI Import' },
{ key: 'namenseinblender', label: 'Namenseinblender' },
]
const activeSubmenu = ref('assignments')
@ -217,11 +218,65 @@ async function updateSetting(key, value) {
<details class="mt-4">
<summary class="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Was tun, wenn der Liedtext nicht erkannt wird?</summary>
<p class="mt-2 text-xs text-blue-700">
Falle zurück auf das manuelle Einfügen: Öffne das Lied auf SongSelect, drücke <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+A</kbd> dann <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+C</kbd>, und klicke dann auf Aus CCLI importieren" in der Song-Datenbank oder im Gottesdienst-Formular.
Falle zurück auf das manuelle Einfügen: Öffne das Lied auf SongSelect, klicke das <strong>Kopier-Symbol</strong> neben dem Liedtext (es kopiert Titel, Liedtext und CCLI-Infos), und klicke dann auf Aus CCLI importieren" in der Song-Datenbank oder im Gottesdienst-Formular.
</p>
</details>
</div>
</div>
<!-- Namenseinblender Macro Settings -->
<div v-if="activeSubmenu === 'namenseinblender'" class="space-y-6">
<div>
<h3 class="text-base font-semibold text-gray-900">Namenseinblender-Makro</h3>
<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>
<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>

View file

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

View file

@ -9,6 +9,7 @@ import { ref, watch, onMounted } from 'vue'
const songs = ref([])
const meta = ref({ current_page: 1, last_page: 1, per_page: 20, total: 0 })
const search = ref('')
const onlyWithContent = ref(true)
const loading = ref(false)
const deleting = ref(null)
const showDeleteConfirm = ref(false)
@ -38,6 +39,7 @@ async function fetchSongs(page = 1) {
if (search.value.trim()) {
params.set('search', search.value.trim())
}
params.set('with_content', onlyWithContent.value ? '1' : '0')
const response = await fetch(`/api/songs?${params}`, {
headers: {
Accept: 'application/json',
@ -61,6 +63,8 @@ watch(search, () => {
debounceTimer = setTimeout(() => fetchSongs(1), 500)
})
watch(onlyWithContent, () => fetchSongs(1))
onMounted(() => fetchSongs())
function goToPage(page) {
@ -68,6 +72,19 @@ function goToPage(page) {
fetchSongs(page)
}
// Open SongSelect search in a new tab AND open the CCLI import dialog so the user can paste.
function openSongSelectSearch() {
const query = search.value.trim()
if (query) {
window.open(
`https://songselect.ccli.com/search/results?search=${encodeURIComponent(query)}`,
'_blank',
'noopener,noreferrer',
)
}
ccliDialogOpen.value = true
}
function formatDate(value) {
if (!value) return ''
return new Date(value).toLocaleDateString('de-DE', {
@ -391,16 +408,15 @@ function pageRange() {
<!-- CCLI Import Buttons -->
<div class="mb-4 flex items-center gap-2">
<a
<button
v-if="search"
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(search)"
target="_blank"
rel="noopener noreferrer"
type="button"
data-testid="songselect-search-button-songdb"
@click="openSongSelectSearch"
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
>
Auf SongSelect suchen
</a>
</button>
<button
type="button"
data-testid="open-ccli-paste-dialog-button-songdb"
@ -409,6 +425,22 @@ function pageRange() {
>
Aus CCLI importieren
</button>
<button
type="button"
data-testid="song-list-content-filter"
@click="onlyWithContent = !onlyWithContent"
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium shadow-sm transition"
:class="onlyWithContent
? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'"
:title="onlyWithContent ? 'Zeige nur Songs mit Inhalt' : 'Zeige auch Songs ohne Inhalt'"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
{{ onlyWithContent ? 'Nur mit Inhalt' : 'Alle Songs' }}
</button>
</div>
<!-- Song Count + Loading -->
@ -461,12 +493,26 @@ function pageRange() {
<tr
v-for="song in songs"
:key="song.id"
class="group transition-colors hover:bg-amber-50/30"
data-testid="song-list-row"
class="group transition-colors"
:class="song.has_content === false
? 'bg-orange-50 hover:bg-orange-100/60'
: 'hover:bg-amber-50/30'"
>
<!-- Titel -->
<td class="px-4 py-3.5">
<div class="font-medium text-gray-900">{{ song.title }}</div>
<div v-if="song.author" class="mt-0.5 text-xs text-gray-400">{{ song.author }}</div>
<div
v-if="song.has_content === false"
data-testid="song-list-no-content-note"
class="mt-1 inline-flex items-center gap-1 rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-semibold text-orange-700"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Ohne Inhalt
</div>
</td>
<!-- CCLI-ID -->
@ -789,5 +835,6 @@ function pageRange() {
mode="songdb"
@close="ccliDialogOpen = false"
@imported="(songId, mode) => { ccliDialogOpen = false; if (mode === 'stay') { router.reload({ only: ['songs'] }) } }"
@edit-song="(id) => { ccliDialogOpen = false; openEditModal({ id }) }"
/>
</template>

View file

@ -121,8 +121,18 @@ async function fetchTextFromUrl() {
}
}
// Mirrors App\Support\CcliLabels::SECTION_LABEL_PATTERN section marks in pasted
// translation text (e.g. "Strophe 1", "Refrain", "Chorus 2") must be ignored so they
// don't shift the line-by-line mapping onto the original slides.
const SECTION_LABEL_PATTERN =
/^(Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu
function stripSectionLabelLines(lines) {
return lines.filter((line) => !SECTION_LABEL_PATTERN.test(line.trim()))
}
function distributeTextToSlides(text) {
const translatedLines = normalizeNewlines(text).split('\n')
const translatedLines = stripSectionLabelLines(normalizeNewlines(text).split('\n'))
let offset = 0
orderedSlides().forEach((slide) => {

View file

@ -8,9 +8,11 @@
use App\Http\Controllers\MacroAssignmentController;
use App\Http\Controllers\MacroImportController;
use App\Http\Controllers\ServiceController;
use App\Http\Controllers\ServiceImageController;
use App\Http\Controllers\ServiceMacroOverrideController;
use App\Http\Controllers\SettingsController;
use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\SongSectionController;
use App\Http\Controllers\SyncController;
use App\Http\Controllers\TranslationController;
use Illuminate\Support\Facades\Auth;
@ -67,6 +69,9 @@
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}/edit', [ServiceController::class, 'edit'])->name('services.edit');
Route::post('/services/{service}/key-visual', [ServiceImageController::class, 'storeKeyVisual'])->name('services.key-visual.store');
Route::post('/services/{service}/background', [ServiceImageController::class, 'storeBackground'])->name('services.background.store');
Route::patch('/services/{service}/name-overrides', [ServiceController::class, 'updateNameOverrides'])->name('services.name-overrides.update');
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');
Route::get('/songs', function () {
@ -86,6 +91,9 @@
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy');
Route::post('/songs/{song}/sections', [SongSectionController::class, 'store'])->name('songs.sections.store');
Route::patch('/songs/{song}/sections/{section}', [SongSectionController::class, 'update'])->name('songs.sections.update');
Route::delete('/songs/{song}/sections/{section}', [SongSectionController::class, 'destroy'])->name('songs.sections.destroy');
Route::get('/songs/{song}/arrangements/{arrangement}/pdf', [SongPdfController::class, 'download'])->name('songs.pdf');
Route::get('/songs/{song}/arrangements/{arrangement}/preview', [SongPdfController::class, 'preview'])->name('songs.preview');

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
use App\Exceptions\DuplicateCcliSongException;
use App\Models\ApiRequestLog;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
@ -44,7 +45,7 @@ function ccliFixture(string $name): string
expect($arrangement)->not->toBeNull()
->and($arrangement->is_default)->toBeTrue()
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5)
->and(SongSlide::count())->toBe(9);
->and(SongSlide::count())->toBe(5);
});
test('imports english and german fixture and stores translated slide text', function () {
@ -55,8 +56,8 @@ function ccliFixture(string $name): string
expect($result['status'])->toBe('created')
->and($song->has_translation)->toBeTrue()
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(4)
->and(SongSlide::where('text_content_translated', 'Deutsche Liedzeile 1 zum gleichen Gedanken')->exists())->toBeTrue();
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(2)
->and(SongSlide::where('text_content_translated', "Deutsche Liedzeile 1 zum gleichen Gedanken\nDeutsche Liedzeile 2 trägt den Refrain vor")->exists())->toBeTrue();
});
test('blocks active duplicate ccli_id with DuplicateCcliSongException', function () {
@ -76,6 +77,39 @@ function ccliFixture(string $name): string
expect(Song::count())->toBe(1);
});
test('fills existing empty ccli song instead of blocking as duplicate', function () {
$emptySong = Song::factory()->create([
'ccli_id' => '4327499',
'title' => 'ChurchTools Platzhalter',
'author' => null,
]);
$result = app(CcliImportService::class)->import(ccliFixture('copy-icon-vers-author-trailing.txt'));
$song = $result['song']->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
$arrangement = $song->arrangements->first();
expect($result['status'])->toBe('restored')
->and($song->id)->toBe($emptySong->id)
->and($song->title)->toBe('Heilig ist der Herr')
->and($song->author)->toBe('Albert Frey')
->and($arrangement)->not->toBeNull()
->and($arrangement->arrangementSections)->toHaveCount(2)
->and(SongSlide::count())->toBe(7);
});
test('uses distinct label colors for imported section kinds', function () {
app(CcliImportService::class)->import(ccliFixture('copy-icon-vers-author-trailing.txt'));
$verse = Label::where('name', 'Verse')->first();
$chorus = Label::where('name', 'Chorus')->first();
expect($verse)->not->toBeNull()
->and($chorus)->not->toBeNull()
->and($verse->color)->toBe('#3B82F6')
->and($chorus->color)->toBe('#10B981')
->and($verse->color)->not->toBe($chorus->color);
});
test('restores soft-deleted song and does not duplicate normal arrangement', function () {
$service = app(CcliImportService::class);
$first = $service->import(ccliFixture('english-only-multi-verse.txt'));

View file

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

View file

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

View file

@ -0,0 +1,94 @@
<?php
use App\Services\FileConversionService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
});
test('cover conversion fills 1920x1080 without black bars', function () {
$service = app(FileConversionService::class);
$file = makePngUploadForFileConversionService('cover.png', 1200, 900);
$result = $service->convertImageCover($file);
expect($result)->toHaveKeys(['filename', 'thumbnail', 'warnings', 'fullCover']);
expect($result['fullCover'])->toBeTrue();
expect(Storage::disk('public')->exists($result['filename']))->toBeTrue();
expect(Storage::disk('public')->exists($result['thumbnail']))->toBeTrue();
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
foreach ([[0, 0], [1919, 0], [0, 1079], [1919, 1079]] as [$x, $y]) {
$corner = imagecolorsforindex($image, imagecolorat($image, $x, $y));
expect($corner['red'] + $corner['green'] + $corner['blue'])->toBeGreaterThan(20);
}
});
test('contain conversion keeps black bars and fullCover false', function () {
$service = app(FileConversionService::class);
$file = makePngUploadForFileConversionService('contain.png', 1200, 900);
$result = $service->convertImage($file);
expect($result)->toHaveKeys(['filename', 'thumbnail', 'warnings', 'fullCover']);
expect($result['fullCover'])->toBeFalse();
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
foreach ([[0, 0], [1919, 0], [0, 1079], [1919, 1079]] as [$x, $y]) {
$corner = imagecolorsforindex($image, imagecolorat($image, $x, $y));
expect($corner['red'] + $corner['green'] + $corner['blue'])->toBeLessThan(20);
}
});
test('cover conversion upscales small sources with German quality warning', function () {
$service = app(FileConversionService::class);
$file = makePngUploadForFileConversionService('small-cover.png', 800, 600);
$result = $service->convertImageCover($file);
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
expect($result['warnings'])->not->toBeEmpty();
expect(implode(' ', $result['warnings']))->toContain('kleiner als 1920×1080');
expect(implode(' ', $result['warnings']))->toContain('hochskaliert');
});
function makePngUploadForFileConversionService(string $name, int $width, int $height): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'cts-cover-');
if ($path === false) {
throw new RuntimeException('Temp-Datei konnte nicht erstellt werden.');
}
$image = imagecreatetruecolor($width, $height);
if ($image === false) {
throw new RuntimeException('Bild konnte nicht erstellt werden.');
}
$red = imagecolorallocate($image, 255, 0, 0);
imagefill($image, 0, 0, $red);
imagepng($image, $path);
return new UploadedFile($path, $name, 'image/png', null, true);
}

View file

@ -0,0 +1,91 @@
<?php
use App\Models\Service;
use App\Models\Setting;
use App\Models\User;
use App\Services\ChurchToolsService;
use App\Services\ServiceImageResolver;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
});
test('finalize nimmt snapshot des globalen hintergrunds wenn service spalte null ist', function () {
Storage::disk('public')->put('slides/bg1.jpg', 'fake-content');
Setting::set('current_background', 'slides/bg1.jpg');
$service = Service::factory()->create([
'background_filename' => null,
'finalized_at' => null,
]);
$this->actingAs(User::factory()->create())
->postJson(route('services.finalize', $service), ['confirmed' => true])
->assertOk();
expect($service->fresh()->background_filename)->toBe('slides/bg1.jpg');
});
test('snapshot gewinnt: neuer globaler hintergrund beeinflusst abgeschlossenen service nicht', function () {
Storage::disk('public')->put('slides/bg1.jpg', 'fake-content');
Setting::set('current_background', 'slides/bg1.jpg');
$service = Service::factory()->create([
'background_filename' => null,
'finalized_at' => null,
]);
$this->actingAs(User::factory()->create())
->postJson(route('services.finalize', $service), ['confirmed' => true])
->assertOk();
Storage::disk('public')->put('slides/bg2.jpg', 'fake-content');
Setting::set('current_background', 'slides/bg2.jpg');
$resolved = app(ServiceImageResolver::class)->backgroundFor($service->fresh());
expect($resolved)->toBe('slides/bg1.jpg');
});
test('cts sync ueberschreibt key_visual_filename und moderator_name nicht', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
$service = Service::factory()->create([
'cts_event_id' => '424242',
'key_visual_filename' => 'custom.jpg',
'moderator_name' => 'Hans',
'preacher_name_override' => 'Pastor X',
'background_filename' => 'bg-custom.jpg',
]);
$sync = new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(
id: 424242,
title: 'Aktualisierter Titel',
startDate: '2026-03-08T10:00:00+00:00',
eventServices: [
new FakeEventService('Predigt', new FakePerson('Neuer', 'Prediger')),
],
),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([]),
eventServiceFetcher: fn (int $eventId) => [
new FakeEventService('Predigt', new FakePerson('Neuer', 'Prediger')),
],
);
$sync->sync();
$fresh = DB::table('services')->where('cts_event_id', '424242')->first();
expect($fresh->key_visual_filename)->toBe('custom.jpg');
expect($fresh->moderator_name)->toBe('Hans');
expect($fresh->preacher_name_override)->toBe('Pastor X');
expect($fresh->background_filename)->toBe('bg-custom.jpg');
expect($fresh->title)->toBe('Aktualisierter Titel');
});

View file

@ -0,0 +1,309 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Setting;
use App\Models\Slide;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class FullPlaylistExportTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_full_service_playlist_includes_all_features_in_correct_order(): void
{
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Setting::set('namenseinblender_macro_uuid', 'macro-uuid-1');
Setting::set('namenseinblender_macro_collection_name', '--MAIN--');
Setting::set('namenseinblender_macro_collection_uuid', 'collection-uuid-1');
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/bg.jpg', 'background-image');
Storage::disk('public')->put('slides/sermon1.jpg', 'sermon-image-1');
Storage::disk('public')->put('slides/sermon2.jpg', 'sermon-image-2');
$service = Service::factory()->create([
'title' => 'Voller Gottesdienst',
'date' => now(),
'key_visual_filename' => 'slides/kv.jpg',
'background_filename' => 'slides/bg.jpg',
'moderator_name' => 'Moderator Max',
'preacher_name' => 'Pastor Paul',
'preacher_name_override' => null,
'has_agenda' => true,
]);
$song = $this->createSongWithContent('Großer Gott');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Großer Gott',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Großer Gott',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 2,
'is_before_event' => false,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon1.jpg',
'stored_filename' => 'slides/sermon1.jpg',
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon2.jpg',
'stored_filename' => 'slides/sermon2.jpg',
'sort_order' => 1,
]);
$slideCountBefore = Slide::count();
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$moderatorIndex = array_search('Moderator', $names, true);
$songIndex = array_search('Großer Gott', $names, true);
$kvIndex = array_search('Keyvisual-Predigt', $names, true);
$preacherIndex = array_search('Predigername', $names, true);
$sermonIndex = array_search('Predigt', $names, true);
$this->assertNotFalse($moderatorIndex, 'Moderator nametag missing');
$this->assertNotFalse($songIndex, 'Song presentation missing');
$this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing');
$this->assertNotFalse($preacherIndex, 'Predigername (preacher nametag) missing');
$this->assertNotFalse($sermonIndex, 'Predigt (sermon slides) missing');
$this->assertSame(0, $moderatorIndex, 'Moderator nametag must be first');
$this->assertLessThan($songIndex, $moderatorIndex);
$this->assertLessThan($kvIndex, $songIndex);
$this->assertLessThan($preacherIndex, $kvIndex, 'Keyvisual must come before preacher nametag');
$this->assertLessThan($sermonIndex, $preacherIndex, 'Preacher nametag must come before sermon slides');
$songParser = $playlist->getEmbeddedSong('Großer Gott.pro');
$this->assertNotNull($songParser, 'Embedded song .pro missing');
$songSlides = $this->allParserSlides($songParser);
$this->assertNotEmpty($songSlides);
foreach ($songSlides as $slide) {
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media');
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
}
$embeddedMedia = $playlist->getEmbeddedMediaFiles();
$this->assertArrayHasKey('BACKGROUND.jpg', $embeddedMedia, 'Background must be embedded under fixed name');
$this->assertSame('background-image', $embeddedMedia['BACKGROUND.jpg']);
$this->assertArrayHasKey('KEY_VISUAL.jpg', $embeddedMedia, 'Key-visual must be embedded under fixed name');
$this->assertSame('keyvisual-image', $embeddedMedia['KEY_VISUAL.jpg']);
$sermonParser = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertNotNull($sermonParser, 'Embedded sermon .pro missing');
$sermonSlides = $this->allParserSlides($sermonParser);
$this->assertCount(2, $sermonSlides);
foreach ($sermonSlides as $slide) {
$this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background media');
}
$this->assertSame($slideCountBefore, Slide::count());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_macro_no_nametags_and_sermon_keeps_keyvisual_and_slides(): void
{
Setting::set('namenseinblender_macro_name', '');
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon1.jpg', 'sermon-image-1');
Storage::disk('public')->put('slides/sermon2.jpg', 'sermon-image-2');
$service = Service::factory()->create([
'title' => 'Ohne Macro',
'date' => now(),
'key_visual_filename' => 'slides/kv.jpg',
'background_filename' => null,
'moderator_name' => 'Moderator Max',
'preacher_name' => 'Pastor Paul',
'preacher_name_override' => null,
'has_agenda' => true,
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon1.jpg',
'stored_filename' => 'slides/sermon1.jpg',
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon2.jpg',
'stored_filename' => 'slides/sermon2.jpg',
'sort_order' => 1,
]);
$slideCountBefore = Slide::count();
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$this->assertNotContains('Moderator', $names);
$this->assertNotContains('Predigername', $names);
$kvIndex = array_search('Keyvisual-Predigt', $names, true);
$sermonIndex = array_search('Predigt', $names, true);
$this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing');
$this->assertNotFalse($sermonIndex, 'Predigt entry missing');
$this->assertLessThan($sermonIndex, $kvIndex, 'Keyvisual must come before sermon slides');
$sermonParser = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertNotNull($sermonParser);
$this->assertCount(2, $this->allParserSlides($sermonParser));
$this->assertSame($slideCountBefore, Slide::count());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_service_without_agenda_falls_back_to_legacy_export_without_crash(): void
{
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
$service = Service::factory()->create([
'title' => 'Ohne Agenda',
'date' => now(),
'key_visual_filename' => null,
'background_filename' => null,
'has_agenda' => false,
]);
$song = $this->createSongWithContent('Legacy Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Legacy Lied',
'order' => 1,
]);
$slideCountBefore = Slide::count();
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$this->assertArrayHasKey('path', $result);
$this->assertArrayHasKey('filename', $result);
$this->assertFileExists($result['path']);
$playlist = ProPlaylistReader::read($result['path']);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$this->assertNotContains('Moderator', $names);
$this->assertNotContains('Predigername', $names);
$this->assertNotContains('Keyvisual-Predigt', $names);
$this->assertNotNull($playlist->getEmbeddedSong('Legacy Lied.pro'));
$this->assertSame($slideCountBefore, Slide::count());
$this->cleanupTempDir($result['temp_dir']);
}
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 1 - '.$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 allParserSlides(\ProPresenter\Parser\Song $parserSong): array
{
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
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);
}
}

View file

@ -0,0 +1,249 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class KeyVisualFallbackTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
$service = Service::factory()->create([
'title' => 'Keyvisual Service',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
$slideCountBefore = Slide::count();
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
$fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro');
$this->assertCount(1, $embeddedProFiles);
$this->assertNotNull($fallbackSong);
$this->assertSame($slideCountBefore, Slide::count());
$slides = $this->allParserSlides($fallbackSong);
$this->assertCount(1, $slides);
$this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$this->assertSame('keyvisual-image', $playlist->getEmbeddedMediaFiles()['KEY_VISUAL.jpg']);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
$service = Service::factory()->create([
'title' => 'Song Service',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
]);
$song = $this->createSongWithContent('Nur ein Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Nur ein Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Nur ein Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertCount(1, $playlist->getEmbeddedProFiles());
$this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro'));
$this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_sequence(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
$service = Service::factory()->create([
'title' => 'Slides Service',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'preacher_name' => 'Pastor Paul',
'preacher_name_override' => null,
]);
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'original_filename' => 'sermon.jpg',
'stored_filename' => 'slides/sermon.jpg',
'sort_order' => 0,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertCount(2, $playlist->getEmbeddedProFiles());
$this->assertNotNull($sermonSong);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$this->assertContains('Keyvisual-Predigt', $names);
$this->assertLessThan(
array_search('Predigt', $names, true),
array_search('Keyvisual-Predigt', $names, true),
);
$slides = $this->allParserSlides($sermonSong);
$this->assertCount(1, $slides);
$this->assertFalse($slides[0]->hasBackgroundMedia());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void
{
$service = Service::factory()->create([
'title' => 'Ohne Keyvisual',
'date' => now(),
'key_visual_filename' => null,
]);
$song = $this->createSongWithContent('Vorhandenes Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Vorhandenes Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung ohne Folien',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Vorhandenes Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 2,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertCount(1, $playlist->getEmbeddedProFiles());
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
$this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
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 1 - '.$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 allParserSlides(\ProPresenter\Parser\Song $parserSong): array
{
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
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);
}
}

View file

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

View file

@ -0,0 +1,140 @@
<?php
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Services\NameTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('moderator override wins', function () {
$service = Service::factory()->create([
'moderator_name' => 'Override Mod',
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Override Mod');
});
test('moderator falls back to first visible agenda item responsibles', function () {
$service = Service::factory()->create([
'moderator_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Vorprogramm',
'is_before_event' => true,
'responsible' => [['name' => 'Ignored Person']],
'sort_order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'responsible' => [
['name' => 'Anna Müller'],
['name' => 'Tom Klein'],
],
'sort_order' => 2,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Später',
'is_before_event' => false,
'responsible' => [['name' => 'Späte Person']],
'sort_order' => 3,
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Anna Müller, Tom Klein');
});
test('moderator returns null without override or visible agenda item', function () {
$service = Service::factory()->create([
'moderator_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'is_before_event' => true,
'responsible' => [['name' => 'Ignored Person']],
'sort_order' => 1,
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBeNull();
});
test('preacher override wins', function () {
$service = Service::factory()->create([
'preacher_name_override' => 'Gast Sprecher',
'preacher_name' => 'Pfr. Lang',
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBe('Gast Sprecher');
});
test('preacher falls back to cts preacher name', function () {
$service = Service::factory()->create([
'preacher_name_override' => null,
'preacher_name' => 'Pfr. Lang',
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBe('Pfr. Lang');
});
test('preacher falls back to sermon agenda item responsibles', function () {
Setting::set('agenda_sermon_matching', 'Predigt*,Sermon*');
$service = Service::factory()->create([
'preacher_name_override' => null,
'preacher_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lied vor der Predigt',
'service_song_id' => null,
'responsible' => [['name' => 'Ignored Person']],
'sort_order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'responsible' => [['name' => 'Diakon Bauer']],
'sort_order' => 2,
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBe('Diakon Bauer');
});
test('preacher returns null without override cts name or sermon responsibles', function () {
Setting::set('agenda_sermon_matching', 'Predigt*');
$service = Service::factory()->create([
'preacher_name_override' => null,
'preacher_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'service_song_id' => null,
'responsible' => [['name' => 'Moderation']],
'sort_order' => 1,
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBeNull();
});

View file

@ -0,0 +1,39 @@
<?php
use App\Models\Setting;
use App\Services\NameTagSlideBuilder;
test('build returns null when namenseinblender macro is not configured', function () {
expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull();
});
test('build returns text lines and macro when configured', function () {
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Setting::set('namenseinblender_macro_uuid', '11111111-1111-1111-1111-111111111111');
Setting::set('namenseinblender_macro_collection_name', 'Service Macros');
Setting::set('namenseinblender_macro_collection_uuid', '22222222-2222-2222-2222-222222222222');
$slide = app(NameTagSlideBuilder::class)->build('Anna Müller', 'Moderation');
expect($slide)->toBe([
'text' => "Anna Müller\nModeration",
'macro' => [
'name' => 'Namenseinblender',
'uuid' => '11111111-1111-1111-1111-111111111111',
'collectionName' => 'Service Macros',
'collectionUuid' => '22222222-2222-2222-2222-222222222222',
],
]);
});
test('convenience methods use moderator and preacher titles', function () {
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Setting::set('namenseinblender_macro_uuid', '11111111-1111-1111-1111-111111111111');
Setting::set('namenseinblender_macro_collection_name', 'Service Macros');
Setting::set('namenseinblender_macro_collection_uuid', '22222222-2222-2222-2222-222222222222');
$builder = app(NameTagSlideBuilder::class);
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration")
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt");
});

View file

@ -0,0 +1,74 @@
<?php
use App\Models\Setting;
use App\Models\User;
beforeEach(function () {
$this->user = User::factory()->create();
});
test('patch namenseinblender macro name and uuid persists settings', function () {
$this->actingAs($this->user)
->patchJson(route('settings.update'), [
'key' => 'namenseinblender_macro_name',
'value' => 'Namenseinblender',
])->assertOk()->assertJson(['success' => true]);
$this->actingAs($this->user)
->patchJson(route('settings.update'), [
'key' => 'namenseinblender_macro_uuid',
'value' => 'ABC-123',
])->assertOk()->assertJson(['success' => true]);
expect(Setting::get('namenseinblender_macro_name'))->toBe('Namenseinblender');
expect(Setting::get('namenseinblender_macro_uuid'))->toBe('ABC-123');
});
test('namenseinblender macro shared prop reflects persisted values', function () {
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Setting::set('namenseinblender_macro_uuid', 'ABC-123');
$this->actingAs($this->user)
->withoutVite()
->get(route('settings.index'))
->assertInertia(
fn ($page) => $page
->where('namenseinblenderMacro.name', 'Namenseinblender')
->where('namenseinblenderMacro.uuid', 'ABC-123')
->where('namenseinblenderMacro.collection_name', '--MAIN--')
);
});
test('current_background setting persists and is shared as prop', function () {
$this->actingAs($this->user)
->patchJson(route('settings.update'), [
'key' => 'current_background',
'value' => 'slides/bg.jpg',
])->assertOk()->assertJson(['success' => true]);
expect(Setting::get('current_background'))->toBe('slides/bg.jpg');
$this->actingAs($this->user)
->withoutVite()
->get(route('settings.index'))
->assertInertia(
fn ($page) => $page->where('currentBackground', 'slides/bg.jpg')
);
});
test('current_key_visual setting persists and is shared as prop', function () {
$this->actingAs($this->user)
->patchJson(route('settings.update'), [
'key' => 'current_key_visual',
'value' => 'slides/key.jpg',
])->assertOk()->assertJson(['success' => true]);
expect(Setting::get('current_key_visual'))->toBe('slides/key.jpg');
$this->actingAs($this->user)
->withoutVite()
->get(route('settings.index'))
->assertInertia(
fn ($page) => $page->where('currentKeyVisual', 'slides/key.jpg')
);
});

View file

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

View file

@ -0,0 +1,291 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Setting;
use App\Models\Slide;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\PlaylistArchive;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class PlaylistSequenceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded_sermon_slides(): void
{
$this->configureNameTagMacro();
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
$service = Service::factory()->create([
'title' => 'Predigt Sequenz',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'preacher_name_override' => 'Erika Predigt',
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
$this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0);
$this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$names = $this->entryNames($playlist);
$offset = $names[0] === 'Moderator' ? 1 : 0;
$this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], array_slice($names, $offset));
$keyVisualSlides = $this->slidesForEntry($playlist, $entries[$offset]);
$this->assertCount(1, $keyVisualSlides);
$this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $keyVisualSlides[0]->getBackgroundMediaUrl());
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
$this->assertCount(1, $nameTagSlides);
$this->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText());
$this->assertTrue($nameTagSlides[0]->hasMacro());
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
$this->assertCount(2, $sermonSlides);
$this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel());
$this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel());
$this->assertSame(2, Slide::count());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_moderator_nametag_is_first_presentation_for_first_visible_agenda_item(): void
{
$this->configureNameTagMacro();
$service = Service::factory()->create([
'title' => 'Moderator Sequenz',
'moderator_name' => 'Max Moderation',
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Vorprogramm',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => true,
'responsible' => [['name' => 'Versteckte Person']],
]);
$song = $this->createSongWithContent('Erstes sichtbares Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Erstes sichtbares Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Erstes sichtbares Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 2,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
$this->assertCount(1, $moderatorSlides);
$this->assertSame("Max Moderation\nModeration", $moderatorSlides[0]->getPlainText());
$this->assertTrue($moderatorSlides[0]->hasMacro());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_macro_configured_no_nametags_are_added_and_sermon_sequence_keeps_keyvisual_then_slides(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
$service = Service::factory()->create([
'title' => 'Ohne Namenseinblender',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'moderator_name' => 'Max Moderation',
'preacher_name_override' => 'Erika Predigt',
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
$this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0);
$this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$this->assertSame(['Keyvisual-Predigt', 'Predigt'], $this->entryNames($playlist));
$this->assertCount(1, $this->slidesForEntry($playlist, $entries[0]));
$sermonSlides = $this->slidesForEntry($playlist, $entries[1]);
$this->assertCount(2, $sermonSlides);
foreach (array_keys($playlist->getEmbeddedProFiles()) as $filename) {
foreach ($this->allParserSlides($playlist->getEmbeddedSong($filename)) as $slide) {
$this->assertFalse($slide->hasMacro());
}
}
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_moderator_name_no_moderator_nametag_is_added(): void
{
$this->configureNameTagMacro();
$service = Service::factory()->create([
'title' => 'Ohne Moderator',
'moderator_name' => null,
]);
$song = $this->createSongWithContent('Startlied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Startlied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Startlied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
'responsible' => [],
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertSame(['Startlied'], $this->entryNames($playlist));
$this->assertNull($playlist->getEmbeddedSong('Moderator.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
private function configureNameTagMacro(): void
{
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Setting::set('namenseinblender_macro_uuid', '11111111-1111-4111-8111-111111111111');
Setting::set('namenseinblender_macro_collection_name', 'Service Macros');
Setting::set('namenseinblender_macro_collection_uuid', '22222222-2222-4222-8222-222222222222');
}
private function createSermonSlide(Service $service, ServiceAgendaItem $agendaItem, string $filename, int $sortOrder): Slide
{
return Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'original_filename' => $filename,
'stored_filename' => 'slides/'.$filename,
'sort_order' => $sortOrder,
]);
}
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 1 - '.$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;
}
/** @return array<int, string> */
private function entryNames(PlaylistArchive $playlist): array
{
return array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
}
private function slidesForEntry(PlaylistArchive $playlist, $entry): array
{
$filename = $entry->getDocumentFilename();
$this->assertNotNull($filename);
return $this->allParserSlides($playlist->getEmbeddedSong($filename));
}
private function allParserSlides(?\ProPresenter\Parser\Song $parserSong): array
{
$this->assertNotNull($parserSong);
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
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);
}
}

View file

@ -7,10 +7,17 @@
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Slide;
use App\Models\Song;
use App\Models\User;
use App\Services\PlaylistExportService;
use App\Services\ProBundleExportService;
use App\Services\ProExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class ProFileExportTest extends TestCase
@ -32,19 +39,21 @@ private function createSongWithContent(): Song
['name' => 'Verse 1 - Export Test Song'],
['color' => '#2196F3'],
);
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$verseSection = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
$verseSection->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verseSection->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$chorus = Label::firstOrCreate(
['name' => 'Chorus - Export Test Song'],
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$chorusSection = $song->sections()->create(['label_id' => $chorus->id, 'order' => 1]);
$chorusSection->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]);
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
$arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 2]);
return $song;
}
@ -113,13 +122,13 @@ public function test_download_pro_roundtrip_preserves_content(): void
$importResponse->assertOk();
$songId = $importResponse->json('songs.0.id');
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId);
$originalSong = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($songId);
$this->assertNotNull($originalSong);
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
$this->assertNotNull($defaultArr);
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
$originalArrangementSections = $defaultArr->arrangementSections->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements;
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
@ -137,21 +146,21 @@ public function test_download_pro_roundtrip_preserves_content(): void
$reImportedGroups = $reImported->getGroups();
$uniqueOriginalLabels = $originalArrangementLabels
->map(fn ($al) => $al->label)
$uniqueOriginalSections = $originalArrangementSections
->map(fn ($arrangementSection) => $arrangementSection->section)
->filter()
->unique('id')
->values();
$this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch');
$this->assertCount($uniqueOriginalSections->count(), $reImportedGroups, 'Group count mismatch');
foreach ($uniqueOriginalLabels as $index => $originalLabel) {
foreach ($uniqueOriginalSections as $index => $originalSection) {
$reImportedGroup = $reImportedGroups[$index];
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
$this->assertSame($originalSection->label->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
$originalSlides = $originalLabel->songSlides->sortBy('order')->values();
$originalSlides = $originalSection->slides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'");
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalSection->label->name}'");
foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex];
@ -159,15 +168,15 @@ public function test_download_pro_roundtrip_preserves_content(): void
$this->assertSame(
$originalSlide->text_content,
$reImportedSlide->getPlainText(),
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
"Slide text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
);
if ($originalSlide->text_content_translated) {
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}");
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalSection->label->name}' slide {$slideIndex}");
$this->assertSame(
$originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(),
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
"Translation text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
);
}
}
@ -180,9 +189,9 @@ public function test_download_pro_roundtrip_preserves_content(): void
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementLabels
$originalGroupNames = $originalArrangement->arrangementSections
->sortBy('order')
->map(fn ($al) => $al->label?->name)
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
->filter()
->values()
->toArray();
@ -268,6 +277,190 @@ public function test_export_mit_ausgeblendeter_macro_enthaelt_keine_macro(): voi
}
}
public function test_export_mit_service_background_enthaelt_background_auf_allen_song_folien(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
$service = Service::factory()->create([
'background_filename' => 'slides/background.jpg',
]);
$song = $this->createSongWithContent();
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
$this->assertNotEmpty($slides);
foreach ($slides as $slide) {
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
}
}
public function test_export_ohne_background_enthaelt_keine_background_actions(): void
{
Storage::fake('public');
$service = Service::factory()->create();
$song = $this->createSongWithContent();
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
foreach ($this->allParserSlides($parserSong) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
}
}
public function test_sermon_export_ueberspringt_background_bei_full_cover_folien(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
Storage::disk('public')->put('slides/contain.jpg', 'contain-image');
Storage::disk('public')->put('slides/cover.jpg', 'cover-image');
$service = Service::factory()->create([
'background_filename' => 'slides/background.jpg',
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
'original_filename' => 'contain.jpg',
'stored_filename' => 'slides/contain.jpg',
'cover_mode' => false,
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
'original_filename' => 'cover.jpg',
'stored_filename' => 'slides/cover.jpg',
'cover_mode' => true,
'sort_order' => 1,
]);
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon');
$bundle = ProBundleReader::read($bundlePath);
$slides = $this->allParserSlides($bundle->getSong());
$this->assertCount(2, $slides);
$this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
$this->assertFalse($slides[1]->hasBackgroundMedia());
$this->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name');
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
@unlink($bundlePath);
}
public function test_information_und_moderation_exports_erhalten_keinen_background(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
Storage::disk('public')->put('slides/information.jpg', 'information-image');
Storage::disk('public')->put('slides/moderation.jpg', 'moderation-image');
$service = Service::factory()->create([
'background_filename' => 'slides/background.jpg',
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'information',
'original_filename' => 'information.jpg',
'stored_filename' => 'slides/information.jpg',
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'moderation',
'original_filename' => 'moderation.jpg',
'stored_filename' => 'slides/moderation.jpg',
'sort_order' => 0,
]);
foreach (['information', 'moderation'] as $blockType) {
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
}
@unlink($bundlePath);
}
}
public function test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
Storage::disk('public')->put('slides/information.jpg', 'information-image');
Storage::disk('public')->put('slides/sermon-contain.jpg', 'sermon-contain-image');
Storage::disk('public')->put('slides/sermon-cover.jpg', 'sermon-cover-image');
$service = Service::factory()->create([
'title' => 'Playlist Background',
'date' => now(),
'background_filename' => 'slides/background.jpg',
]);
Slide::factory()->create([
'service_id' => null,
'type' => 'information',
'original_filename' => 'information.jpg',
'stored_filename' => 'slides/information.jpg',
'uploaded_at' => now()->subDay(),
'sort_order' => 0,
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon-contain.jpg',
'stored_filename' => 'slides/sermon-contain.jpg',
'cover_mode' => false,
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon-cover.jpg',
'stored_filename' => 'slides/sermon-cover.jpg',
'cover_mode' => true,
'sort_order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$informationSong = $playlist->getEmbeddedSong('Informationen.pro');
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertNotNull($informationSong);
$this->assertNotNull($sermonSong);
foreach ($this->allParserSlides($informationSong) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
}
$sermonSlides = $this->allParserSlides($sermonSong);
$this->assertCount(2, $sermonSlides);
$this->assertTrue($sermonSlides[0]->hasBackgroundMedia());
$this->assertFalse($sermonSlides[1]->hasBackgroundMedia());
$this->cleanupTempDir($result['temp_dir']);
}
private function createMacroForExport(string $name, array $attributes = []): Macro
{
$macro = Macro::factory()->create(array_merge([
@ -305,4 +498,27 @@ private function assertStringContains(string $needle, ?string $haystack): void
"Failed asserting that '{$haystack}' contains '{$needle}'"
);
}
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);
}
}

View file

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

View file

@ -0,0 +1,52 @@
<?php
use App\Models\Service;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
// Migration rollback is validated via schema assertions; run `ddev exec php artisan migrate:rollback --step=1` manually if needed.
});
test('key visual url returns storage path when filename exists', function () {
$service = Service::factory()->create([
'key_visual_filename' => 'slides/a.jpg',
]);
expect($service->keyVisualUrl)->toBe('/storage/slides/a.jpg');
});
test('key visual url returns null when filename is missing', function () {
$service = Service::factory()->create([
'key_visual_filename' => null,
]);
expect($service->keyVisualUrl)->toBeNull();
});
test('service fillable persists image and name override columns', function () {
$service = Service::factory()->create();
$service->fill([
'key_visual_filename' => 'slides/key.jpg',
'background_filename' => 'slides/background.jpg',
'moderator_name' => 'Max Mustermann',
'preacher_name_override' => 'Lisa Beispiel',
]);
$service->save();
$fresh = $service->fresh();
expect($fresh->key_visual_filename)->toBe('slides/key.jpg')
->and($fresh->background_filename)->toBe('slides/background.jpg')
->and($fresh->moderator_name)->toBe('Max Mustermann')
->and($fresh->preacher_name_override)->toBe('Lisa Beispiel')
->and($fresh->backgroundUrl)->toBe('/storage/slides/background.jpg');
});
test('services table has the new image and override columns', function () {
expect(Schema::hasColumn('services', 'key_visual_filename'))->toBeTrue()
->and(Schema::hasColumn('services', 'background_filename'))->toBeTrue()
->and(Schema::hasColumn('services', 'moderator_name'))->toBeTrue()
->and(Schema::hasColumn('services', 'preacher_name_override'))->toBeTrue();
});

View file

@ -0,0 +1,159 @@
<?php
use App\Models\Service;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
Queue::fake();
$this->user = User::factory()->create();
$this->actingAs($this->user);
$this->service = Service::factory()->create();
});
/*
|--------------------------------------------------------------------------
| Key-Visual Upload
|--------------------------------------------------------------------------
*/
test('key visual upload with service scope sets column only', function () {
$file = makeImageUpload('keyvisual.png', 800, 600);
$response = $this->post(route('services.key-visual.store', $this->service), [
'file' => $file,
'scope' => 'service',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->service->refresh();
expect($this->service->key_visual_filename)->not->toBeNull();
expect($this->service->key_visual_filename)->toStartWith('slides/');
expect($this->service->key_visual_filename)->toEndWith('.jpg');
expect(Storage::disk('public')->exists($this->service->key_visual_filename))->toBeTrue();
expect(Setting::get('current_key_visual'))->toBeNull();
});
/*
|--------------------------------------------------------------------------
| Background Upload (default scope)
|--------------------------------------------------------------------------
*/
test('background upload with default scope sets column and global setting', function () {
$file = makeImageUpload('background.png', 1920, 1080);
$response = $this->post(route('services.background.store', $this->service), [
'file' => $file,
'scope' => 'default',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->service->refresh();
expect($this->service->background_filename)->not->toBeNull();
expect($this->service->background_filename)->toStartWith('slides/');
expect(Storage::disk('public')->exists($this->service->background_filename))->toBeTrue();
expect(Setting::get('current_background'))->toBe($this->service->background_filename);
});
/*
|--------------------------------------------------------------------------
| Validation
|--------------------------------------------------------------------------
*/
test('key visual upload rejects non-image file with german error', function () {
$file = UploadedFile::fake()->create('notes.txt', 10, 'text/plain');
$response = $this->postJson(route('services.key-visual.store', $this->service), [
'file' => $file,
'scope' => 'service',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['file']);
expect($response->json('errors.file.0'))->toContain('Bild');
$this->service->refresh();
expect($this->service->key_visual_filename)->toBeNull();
});
test('background upload rejects invalid scope', function () {
$file = makeImageUpload('background.png', 800, 600);
$response = $this->postJson(route('services.background.store', $this->service), [
'file' => $file,
'scope' => 'bogus',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['scope']);
});
test('key visual upload does not delete previous file', function () {
$first = makeImageUpload('first.png', 800, 600);
$this->post(route('services.key-visual.store', $this->service), [
'file' => $first,
'scope' => 'service',
]);
$this->service->refresh();
$oldFilename = $this->service->key_visual_filename;
$second = makeImageUpload('second.png', 800, 600);
$this->post(route('services.key-visual.store', $this->service), [
'file' => $second,
'scope' => 'service',
]);
$this->service->refresh();
expect($this->service->key_visual_filename)->not->toBe($oldFilename);
expect(Storage::disk('public')->exists($oldFilename))->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| Auth
|--------------------------------------------------------------------------
*/
test('key visual upload requires authentication', function () {
auth()->logout();
$file = makeImageUpload('keyvisual.png', 800, 600);
$response = $this->post(route('services.key-visual.store', $this->service), [
'file' => $file,
'scope' => 'service',
]);
$response->assertRedirect(route('login'));
});
function makeImageUpload(string $name = 'test.png', int $w = 800, int $h = 600): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'cts-svc-img-');
if ($path === false) {
throw new RuntimeException('Temp-Datei konnte nicht erstellt werden.');
}
$image = imagecreatetruecolor($w, $h);
if ($image === false) {
throw new RuntimeException('Bild konnte nicht erstellt werden.');
}
$blue = imagecolorallocate($image, 0, 0, 255);
imagefill($image, 0, 0, $blue);
imagepng($image, $path);
imagedestroy($image);
return new UploadedFile($path, $name, 'image/png', null, true);
}

View file

@ -0,0 +1,73 @@
<?php
use App\Models\Service;
use App\Models\Setting;
use App\Services\ServiceImageResolver;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
});
test('key visual uses service file when present', function () {
Storage::disk('public')->put('slides/kv.jpg', 'fake-content');
Setting::set('current_key_visual', 'slides/global-kv.jpg');
$service = Service::factory()->create([
'key_visual_filename' => 'slides/kv.jpg',
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBe('slides/kv.jpg');
});
test('key visual falls back to global setting when service value is empty', function () {
Storage::disk('public')->put('slides/global-kv.jpg', 'fake-content');
Setting::set('current_key_visual', 'slides/global-kv.jpg');
$service = Service::factory()->create([
'key_visual_filename' => null,
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBe('slides/global-kv.jpg');
});
test('key visual returns null when service and global values are empty', function () {
$service = Service::factory()->create([
'key_visual_filename' => null,
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBeNull();
});
test('key visual skips missing service file and falls through to existing global file', function () {
Storage::disk('public')->put('slides/global-kv.jpg', 'fake-content');
Setting::set('current_key_visual', 'slides/global-kv.jpg');
$service = Service::factory()->create([
'key_visual_filename' => 'slides/kv-missing.jpg',
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBe('slides/global-kv.jpg');
});
test('background uses service file falls back to global and returns null when none exist', function () {
Storage::disk('public')->put('slides/bg.jpg', 'fake-content');
Setting::set('current_background', 'slides/global-bg.jpg');
$service = Service::factory()->create([
'background_filename' => 'slides/bg.jpg',
]);
expect(app(ServiceImageResolver::class)->backgroundFor($service))->toBe('slides/bg.jpg');
Storage::disk('public')->delete('slides/bg.jpg');
Storage::disk('public')->put('slides/global-bg.jpg', 'fake-content');
$service->update(['background_filename' => null]);
expect(app(ServiceImageResolver::class)->backgroundFor($service->refresh()))->toBe('slides/global-bg.jpg');
Storage::disk('public')->delete('slides/global-bg.jpg');
Setting::set('current_background', null);
expect(app(ServiceImageResolver::class)->backgroundFor($service->refresh()))->toBeNull();
});

View file

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

View file

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

View file

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

View file

@ -44,12 +44,41 @@
$response->assertOk()
->assertJsonStructure([
'data' => [['id', 'title', 'ccli_id', 'has_translation', 'created_at', 'updated_at']],
'data' => [['id', 'title', 'ccli_id', 'has_translation', 'has_content', 'created_at', 'updated_at']],
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
]);
expect($response->json('meta.total'))->toBe(3);
});
test('songs api marks songs without slides as no content', function () {
$song = Song::factory()->create();
$response = $this->actingAs($this->user)
->getJson('/api/songs');
$response->assertOk();
expect($response->json('data.0.has_content'))->toBeFalse();
});
test('songs api with_content filter hides songs without content', function () {
$withContent = Song::factory()->create(['title' => 'Mit Inhalt']);
$section = $withContent->sections()->create([
'label_id' => \App\Models\Label::factory()->create()->id,
'order' => 1,
]);
$section->slides()->create(['order' => 1, 'text_content' => 'Zeile']);
Song::factory()->create(['title' => 'Ohne Inhalt']);
$response = $this->actingAs($this->user)
->getJson('/api/songs?with_content=1');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.title'))->toBe('Mit Inhalt');
expect($response->json('data.0.has_content'))->toBeTrue();
});
test('songs api search filters by title', function () {
Song::factory()->create(['title' => 'Großer Gott wir loben dich']);
Song::factory()->create(['title' => 'Amazing Grace']);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test'
test.use({ storageState: 'tests/e2e/.auth/user.json' })
test.describe('Keyvisual & Background Panels', () => {
test('Settings page has Namenseinblender submenu and field', async ({ page }) => {
await page.goto('/settings')
await expect(page.getByTestId('settings-submenu-namenseinblender').first()).toBeVisible()
await page.getByTestId('settings-submenu-namenseinblender').first().click()
await expect(page.getByTestId('namenseinblender-macro')).toBeVisible()
})
test('service edit page shows keyvisual and background panels', async ({ page }) => {
await page.goto('/services')
// Find an edit link
const editLink = page.getByRole('link', { name: /Bearbeiten/i }).first()
const count = await page.getByRole('link', { name: /Bearbeiten/i }).count()
if (count === 0) {
// No services in DB — skip gracefully
test.skip(true, 'No services available in test DB')
return
}
await editLink.click()
await page.waitForURL(/\/services\/\d+\/edit/)
// Both panels must be visible
await expect(page.getByTestId('keyvisual-panel')).toBeVisible()
await expect(page.getByTestId('background-panel')).toBeVisible()
})
test('panels show upload inputs', async ({ page }) => {
await page.goto('/services')
const count = await page.getByRole('link', { name: /Bearbeiten/i }).count()
if (count === 0) {
test.skip(true, 'No services available in test DB')
return
}
await page.getByRole('link', { name: /Bearbeiten/i }).first().click()
await page.waitForURL(/\/services\/\d+\/edit/)
// Upload inputs must exist (hidden but present in DOM)
const kvInput = page.getByTestId('keyvisual-panel-upload-input')
const bgInput = page.getByTestId('background-panel-upload-input')
await expect(kvInput).toBeAttached()
await expect(bgInput).toBeAttached()
})
test('panels show German labels', async ({ page }) => {
await page.goto('/services')
const count = await page.getByRole('link', { name: /Bearbeiten/i }).count()
if (count === 0) {
test.skip(true, 'No services available in test DB')
return
}
await page.getByRole('link', { name: /Bearbeiten/i }).first().click()
await page.waitForURL(/\/services\/\d+\/edit/)
// German labels in panels
await expect(page.getByTestId('keyvisual-panel').getByText('Keyvisual')).toBeVisible()
await expect(page.getByTestId('background-panel').getByText('Hintergrundbild')).toBeVisible()
})
})

Some files were not shown because too many files have changed in this diff Show more