Compare commits
No commits in common. "ff3484466bacec046960ac2f777aa7010ce261e7" and "0a345aa3b265963be37b1e5a514e343a2d115a7d" have entirely different histories.
ff3484466b
...
0a345aa3b2
|
|
@ -1,19 +0,0 @@
|
|||
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`
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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).
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
|
||||
PASS Tests\Feature\FileConversionServiceTest
|
||||
✓ contain conversion keeps black bars and fullCover false 0.89s
|
||||
|
||||
Tests: 1 passed (12 assertions)
|
||||
Duration: 1.06s
|
||||
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# 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
107
AGENTS.md
|
|
@ -140,103 +140,6 @@ ### 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):
|
||||
|
|
@ -244,9 +147,9 @@ ## Repository Structure
|
|||
| Repo | Path | Branch | Purpose |
|
||||
|------|------|--------|---------|
|
||||
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
|
||||
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
|
||||
| **propresenter-work** | `/Users/thorsten/AI/propresenter-work/php` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
|
||||
|
||||
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
|
||||
The parser is linked via `composer.json` path repository: `"url": "../propresenter-work/php"`.
|
||||
|
||||
## Build, Test, Lint Commands
|
||||
|
||||
|
|
@ -297,10 +200,10 @@ # Migrations
|
|||
ddev exec php artisan migrate
|
||||
```
|
||||
|
||||
### propresenter (Parser Module)
|
||||
### propresenter-work (Parser Module)
|
||||
|
||||
```bash
|
||||
cd /Users/thorsten/AI/propresenter
|
||||
cd /Users/thorsten/AI/propresenter-work/php
|
||||
|
||||
# Run all tests (230 tests)
|
||||
./vendor/bin/phpunit
|
||||
|
|
@ -325,7 +228,7 @@ ## Architecture
|
|||
tests/Feature/ # Pest v4 / PHPUnit feature tests
|
||||
tests/e2e/ # Playwright browser tests (TypeScript)
|
||||
|
||||
propresenter/
|
||||
propresenter-work/php/
|
||||
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
|
||||
tests/ # PHPUnit 11 tests with #[Test] attributes
|
||||
ref/ # .pro fixture files for testing
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@
|
|||
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
|
||||
{
|
||||
|
|
@ -31,18 +29,18 @@ public function store(Request $request, Song $song): RedirectResponse
|
|||
return;
|
||||
}
|
||||
|
||||
$arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get();
|
||||
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
|
||||
|
||||
$rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [
|
||||
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'label_id' => $al->label_id,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementSections()->insert($rows);
|
||||
$arrangement->arrangementLabels()->insert($rows);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -56,7 +54,7 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
|
|||
]);
|
||||
|
||||
DB::transaction(function () use ($arrangement, $data): void {
|
||||
$arrangement->loadMissing('arrangementSections');
|
||||
$arrangement->loadMissing('arrangementLabels');
|
||||
|
||||
$clone = $arrangement->song->arrangements()->create([
|
||||
'name' => $data['name'],
|
||||
|
|
@ -73,23 +71,22 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
{
|
||||
$data = $request->validate([
|
||||
'groups' => ['array'],
|
||||
'groups.*.section_id' => ['nullable', 'integer', 'exists:song_sections,id'],
|
||||
'groups.*.label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'],
|
||||
'groups.*.order' => ['required', 'integer', 'min:1'],
|
||||
'group_colors' => ['sometimes', 'array'],
|
||||
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
]);
|
||||
|
||||
$sectionIds = $this->sectionIdsForGroups($arrangement, $data['groups'] ?? []);
|
||||
$labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
|
||||
|
||||
DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
|
||||
$arrangement->arrangementSections()->delete();
|
||||
DB::transaction(function () use ($arrangement, $labelIds, $data): void {
|
||||
$arrangement->arrangementLabels()->delete();
|
||||
|
||||
$rows = $sectionIds
|
||||
$rows = $labelIds
|
||||
->values()
|
||||
->map(fn (int $sectionId, int $index) => [
|
||||
->map(fn (int $labelId, int $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $sectionId,
|
||||
'label_id' => $labelId,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
|
@ -97,19 +94,12 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementSections()->insert($rows);
|
||||
$arrangement->arrangementLabels()->insert($rows);
|
||||
}
|
||||
|
||||
if (! empty($data['group_colors'])) {
|
||||
$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]);
|
||||
foreach ($data['group_colors'] as $labelId => $color) {
|
||||
Label::whereKey((int) $labelId)->update(['color' => $color]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -146,56 +136,22 @@ private function cloneArrangementLabels(?SongArrangement $source, SongArrangemen
|
|||
return;
|
||||
}
|
||||
|
||||
$arrangementSections = $source->arrangementSections
|
||||
$arrangementLabels = $source->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values();
|
||||
|
||||
$rows = $arrangementSections
|
||||
->map(fn ($arrangementSection) => [
|
||||
$rows = $arrangementLabels
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'song_arrangement_id' => $target->id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $arrangementSection->order,
|
||||
'label_id' => $arrangementLabel->label_id,
|
||||
'order' => $arrangementLabel->order,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$target->arrangementSections()->insert($rows);
|
||||
$target->arrangementLabels()->insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
private function sectionIdsForGroups(SongArrangement $arrangement, array $groups): \Illuminate\Support\Collection
|
||||
{
|
||||
$songId = $arrangement->song_id;
|
||||
$sectionIds = collect($groups)->map(function (array $group) use ($songId) {
|
||||
if (isset($group['section_id'])) {
|
||||
$section = SongSection::find((int) $group['section_id']);
|
||||
|
||||
if ($section === null || (int) $section->song_id !== (int) $songId) {
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Diese Sektion gehört nicht zu diesem Song.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $section->id;
|
||||
}
|
||||
|
||||
if (isset($group['label_id'])) {
|
||||
$section = SongSection::where('song_id', $songId)
|
||||
->where('label_id', (int) $group['label_id'])
|
||||
->first();
|
||||
|
||||
if ($section !== null) {
|
||||
return $section->id;
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Bitte wähle gültige Song-Sektionen aus.',
|
||||
]);
|
||||
})->values();
|
||||
|
||||
return $sectionIds;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,14 @@
|
|||
|
||||
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(Request $request): Response
|
||||
public function show(): Response
|
||||
{
|
||||
$appUrl = rtrim($request->getSchemeAndHttpHost(), '/');
|
||||
|
||||
if ($appUrl === '') {
|
||||
$appUrl = rtrim((string) Config::get('app.url', ''), '/');
|
||||
}
|
||||
|
||||
$bookmarkletScript = <<<'BOOKMARKLET'
|
||||
(function(){
|
||||
|
|
@ -23,42 +18,20 @@ public function show(Request $request): Response
|
|||
alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).');
|
||||
return;
|
||||
}
|
||||
function send(text){
|
||||
var ccliMatch = (text || '').match(/CCLI[\s#-]*(\d+)/i);
|
||||
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] : '';
|
||||
var payload = {
|
||||
title: '',
|
||||
author: '',
|
||||
ccliId: ccliMatch ? ccliMatch[1] : '',
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
ccliId: ccliId,
|
||||
sourceUrl: location.href,
|
||||
rawText: text || ''
|
||||
rawText: bodyText
|
||||
};
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -126,23 +126,21 @@ public function index(): Response
|
|||
]);
|
||||
}
|
||||
|
||||
public function edit(Service $service, \App\Services\ServiceImageResolver $imageResolver): Response
|
||||
public function edit(Service $service): Response
|
||||
{
|
||||
$service->load([
|
||||
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
||||
'serviceSongs.song',
|
||||
'serviceSongs.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSongs.song.arrangements.arrangementLabels.label',
|
||||
'serviceSongs.arrangement',
|
||||
'slides',
|
||||
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'agendaItems.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'agendaItems.serviceSong.arrangement.arrangementLabels.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) => [
|
||||
|
|
@ -150,7 +148,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'title' => $song->title,
|
||||
'ccli_id' => $song->ccli_id,
|
||||
'has_translation' => $song->has_translation,
|
||||
'has_content' => (int) $song->content_slides_count > 0,
|
||||
])
|
||||
->values();
|
||||
|
||||
|
|
@ -258,13 +255,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
];
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
@ -275,14 +265,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'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,
|
||||
|
|
@ -307,14 +289,13 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'id' => $arrangement->id,
|
||||
'name' => $arrangement->name,
|
||||
'is_default' => $arrangement->is_default,
|
||||
'groups' => $arrangement->arrangementSections
|
||||
'groups' => $arrangement->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->section?->label?->id,
|
||||
'section_id' => $arrangementSection->section?->id,
|
||||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'id' => $arrangementLabel->label?->id,
|
||||
'name' => $arrangementLabel->label?->name,
|
||||
'color' => $arrangementLabel->label?->color,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values(),
|
||||
|
|
@ -362,38 +343,12 @@ 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([
|
||||
|
|
@ -487,15 +442,14 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection
|
|||
return collect();
|
||||
}
|
||||
|
||||
return $defaultArr->arrangementSections
|
||||
return $defaultArr->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->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,
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'id' => $arrangementLabel->label?->id,
|
||||
'name' => $arrangementLabel->label?->name,
|
||||
'color' => $arrangementLabel->label?->color,
|
||||
'order' => $arrangementLabel->order,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values();
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
<?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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -21,12 +21,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ 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(),
|
||||
|
|
@ -261,7 +260,6 @@ 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(),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
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;
|
||||
|
|
@ -18,8 +17,7 @@ public function __construct(
|
|||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Song::query()
|
||||
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')]);
|
||||
$query = Song::query();
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
|
|
@ -28,12 +26,6 @@ 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));
|
||||
|
||||
|
|
@ -44,7 +36,6 @@ 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(),
|
||||
|
|
@ -71,13 +62,13 @@ public function store(SongRequest $request): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich erstellt',
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id);
|
||||
$song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
|
|
@ -100,7 +91,7 @@ public function update(SongRequest $request, int $id): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich aktualisiert',
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -119,24 +110,22 @@ public function destroy(int $id): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
public function formatSongDetail(Song $song): array
|
||||
private function formatSongDetail(Song $song): array
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true);
|
||||
|
||||
$groupsPayload = [];
|
||||
if ($defaultArr !== null) {
|
||||
$groupsPayload = $defaultArr->arrangementSections
|
||||
$groupsPayload = $defaultArr->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->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
|
||||
->map(fn ($al) => [
|
||||
'id' => $al->label?->id,
|
||||
'name' => $al->label?->name,
|
||||
'color' => $al->label?->color,
|
||||
'order' => $al->order,
|
||||
'slides' => $al->label
|
||||
? $al->label->songSlides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
|
|
@ -164,24 +153,14 @@ public 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->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,
|
||||
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
|
||||
'id' => $al->id,
|
||||
'label_id' => $al->label_id,
|
||||
'order' => $al->order,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -57,23 +57,21 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
|
|||
private function buildGroupsInOrder(SongArrangement $arrangement): array
|
||||
{
|
||||
$arrangement->load([
|
||||
'arrangementSections' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementSections.section.slides' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementSections.section.label',
|
||||
'arrangementLabels' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'),
|
||||
]);
|
||||
|
||||
return $arrangement->arrangementSections->map(function ($arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
return $arrangement->arrangementLabels->map(function ($arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($section === null || $label === null) {
|
||||
if ($label === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $label->name,
|
||||
'color' => $label->color ?? '#6b7280',
|
||||
'slides' => $section->slides->map(fn ($slide) => [
|
||||
'slides' => $label->songSlides->map(fn ($slide) => [
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values()->all(),
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangementSection;
|
||||
use App\Models\SongSection;
|
||||
use App\Support\CcliLabels;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongSectionController extends Controller
|
||||
{
|
||||
private const DEFAULT_LABEL_COLOR = '#3B82F6';
|
||||
|
||||
public function __construct(
|
||||
private readonly SongController $songController,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, Song $song): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'label_name' => ['required', 'string', 'max:255'],
|
||||
'color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'slides' => ['array'],
|
||||
'slides.*.text_content' => ['required', 'string'],
|
||||
'slides.*.text_content_translated' => ['nullable', 'string'],
|
||||
], $this->validationMessages());
|
||||
|
||||
$normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']);
|
||||
|
||||
$responseSong = DB::transaction(function () use ($song, $data, $normalizedLabelName): Song {
|
||||
$label = Label::firstOrCreate(
|
||||
['name' => $normalizedLabelName],
|
||||
['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR],
|
||||
);
|
||||
|
||||
if ($song->sections()->where('label_id', $label->id)->exists()) {
|
||||
abort(response()->json([
|
||||
'message' => 'Dieser Abschnitt existiert bereits in diesem Lied.',
|
||||
], 422));
|
||||
}
|
||||
|
||||
$section = $song->sections()->create([
|
||||
'label_id' => $label->id,
|
||||
'order' => ((int) $song->sections()->max('order')) + 1,
|
||||
]);
|
||||
|
||||
$this->replaceSlides($section, $data['slides'] ?? []);
|
||||
|
||||
$defaultArrangement = $song->arrangements()->firstOrCreate(
|
||||
['is_default' => true],
|
||||
['name' => 'Normal'],
|
||||
);
|
||||
|
||||
$defaultArrangement->arrangementSections()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'order' => ((int) $defaultArrangement->arrangementSections()->max('order')) + 1,
|
||||
]);
|
||||
|
||||
$this->recomputeHasTranslation($song);
|
||||
|
||||
return $this->freshSong($song);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sektion wurde hinzugefügt.',
|
||||
'data' => $this->songController->formatSongDetail($responseSong),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, Song $song, SongSection $section): JsonResponse
|
||||
{
|
||||
if ((int) $section->song_id !== (int) $song->id) {
|
||||
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'slides' => ['required', 'array'],
|
||||
'slides.*.text_content' => ['required', 'string'],
|
||||
'slides.*.text_content_translated' => ['nullable', 'string'],
|
||||
'order' => ['sometimes', 'integer'],
|
||||
], $this->validationMessages());
|
||||
|
||||
$responseSong = DB::transaction(function () use ($song, $section, $data): Song {
|
||||
if (array_key_exists('order', $data)) {
|
||||
$section->update(['order' => $data['order']]);
|
||||
}
|
||||
|
||||
$this->replaceSlides($section, $data['slides']);
|
||||
$this->recomputeHasTranslation($song);
|
||||
|
||||
return $this->freshSong($song);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sektion wurde gespeichert.',
|
||||
'data' => $this->songController->formatSongDetail($responseSong),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Song $song, SongSection $section): JsonResponse
|
||||
{
|
||||
if ((int) $section->song_id !== (int) $song->id) {
|
||||
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
|
||||
}
|
||||
|
||||
$responseSong = DB::transaction(function () use ($song, $section): Song {
|
||||
SongArrangementSection::query()
|
||||
->where('song_section_id', $section->id)
|
||||
->whereHas('arrangement', fn ($query) => $query->where('song_id', $song->id))
|
||||
->delete();
|
||||
|
||||
$section->slides()->delete();
|
||||
$section->delete();
|
||||
|
||||
$this->recomputeHasTranslation($song);
|
||||
|
||||
return $this->freshSong($song);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sektion wurde gelöscht.',
|
||||
'data' => $this->songController->formatSongDetail($responseSong),
|
||||
]);
|
||||
}
|
||||
|
||||
private function replaceSlides(SongSection $section, array $slides): void
|
||||
{
|
||||
$section->slides()->delete();
|
||||
|
||||
foreach (array_values($slides) as $index => $slide) {
|
||||
$section->slides()->create([
|
||||
'order' => $index + 1,
|
||||
'text_content' => $slide['text_content'],
|
||||
'text_content_translated' => $slide['text_content_translated'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function recomputeHasTranslation(Song $song): void
|
||||
{
|
||||
$hasTranslation = $song->sections()
|
||||
->whereHas('slides', fn ($query) => $query
|
||||
->whereNotNull('text_content_translated')
|
||||
->where('text_content_translated', '!=', ''))
|
||||
->exists();
|
||||
|
||||
$song->update(['has_translation' => $hasTranslation]);
|
||||
}
|
||||
|
||||
private function freshSong(Song $song): Song
|
||||
{
|
||||
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
}
|
||||
|
||||
private function validationMessages(): array
|
||||
{
|
||||
return [
|
||||
'label_name.required' => 'Bitte gib einen Namen für die Sektion ein.',
|
||||
'label_name.string' => 'Der Sektionsname muss ein Text sein.',
|
||||
'label_name.max' => 'Der Sektionsname darf höchstens 255 Zeichen lang sein.',
|
||||
'color.regex' => 'Bitte gib eine gültige Hex-Farbe an.',
|
||||
'slides.required' => 'Bitte gib mindestens eine Folie an.',
|
||||
'slides.array' => 'Die Folien müssen als Liste gesendet werden.',
|
||||
'slides.*.text_content.required' => 'Bitte gib einen Text für jede Folie ein.',
|
||||
'slides.*.text_content.string' => 'Der Folientext muss ein Text sein.',
|
||||
'slides.*.text_content_translated.string' => 'Der übersetzte Folientext muss ein Text sein.',
|
||||
'order.integer' => 'Die Reihenfolge muss eine Zahl sein.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -19,27 +19,24 @@ public function page(Song $song): Response
|
|||
{
|
||||
$song->load([
|
||||
'arrangements' => fn ($q) => $q->where('is_default', true),
|
||||
'arrangements.arrangementSections' => fn ($q) => $q->orderBy('order'),
|
||||
'arrangements.arrangementSections.section.slides',
|
||||
'arrangements.arrangementSections.section.label',
|
||||
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'),
|
||||
'arrangements.arrangementLabels.label.songSlides',
|
||||
]);
|
||||
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
$groups = collect();
|
||||
if ($defaultArr !== null) {
|
||||
$groups = $defaultArr->arrangementSections
|
||||
$groups = $defaultArr->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->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
|
||||
->map(fn ($al) => [
|
||||
'id' => $al->label?->id,
|
||||
'name' => $al->label?->name,
|
||||
'color' => $al->label?->color,
|
||||
'order' => $al->order,
|
||||
'slides' => $al->label
|
||||
? $al->label->songSlides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
|
|
|
|||
|
|
@ -53,14 +53,6 @@ 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,11 +17,7 @@ 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',
|
||||
|
|
@ -49,16 +45,6 @@ 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');
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ class Slide extends Model
|
|||
'original_filename',
|
||||
'stored_filename',
|
||||
'thumbnail_filename',
|
||||
'cover_mode',
|
||||
'expire_date',
|
||||
'uploader_name',
|
||||
'uploaded_at',
|
||||
|
|
@ -31,7 +30,6 @@ protected function casts(): array
|
|||
return [
|
||||
'expire_date' => 'date',
|
||||
'uploaded_at' => 'datetime',
|
||||
'cover_mode' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,11 +41,6 @@ public function arrangements(): HasMany
|
|||
return $this->hasMany(SongArrangement::class);
|
||||
}
|
||||
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSection::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function serviceSongs(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceSong::class);
|
||||
|
|
|
|||
|
|
@ -31,12 +31,7 @@ public function song(): BelongsTo
|
|||
|
||||
public function arrangementLabels(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
|
||||
}
|
||||
|
||||
public function arrangementSections(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
|
||||
return $this->hasMany(SongArrangementLabel::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function serviceSongs(): HasMany
|
||||
|
|
|
|||
|
|
@ -2,4 +2,27 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
class SongArrangementLabel extends SongArrangementSection {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SongSection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_id',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function song(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Song::class);
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
|
||||
public function slides(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSlide::class, 'song_section_id')->orderBy('order');
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,15 @@ class SongSlide extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_section_id',
|
||||
'label_id',
|
||||
'order',
|
||||
'text_content',
|
||||
'text_content_translated',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function section(): BelongsTo
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SongSection::class, 'song_section_id');
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\SongSlide;
|
||||
use App\Services\DTO\ParsedCcliSection;
|
||||
use App\Services\DTO\ParsedCcliSong;
|
||||
use App\Support\CcliLabels;
|
||||
|
|
@ -18,23 +18,7 @@
|
|||
|
||||
final class CcliImportService
|
||||
{
|
||||
/**
|
||||
* 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',
|
||||
];
|
||||
private const DEFAULT_LABEL_COLOR = '#3B82F6';
|
||||
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
|
|
@ -53,7 +37,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() && $this->songHasContent($song)) {
|
||||
if ($song !== null && ! $song->trashed()) {
|
||||
throw new DuplicateCcliSongException($song->id);
|
||||
}
|
||||
|
||||
|
|
@ -74,34 +58,23 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
$warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.';
|
||||
}
|
||||
|
||||
$sectionIds = [];
|
||||
$labelIds = [];
|
||||
$hasTranslation = false;
|
||||
|
||||
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;
|
||||
foreach ($parsed->sections as $section) {
|
||||
$label = $this->resolveLabel($section);
|
||||
$labelIds[] = $label->id;
|
||||
|
||||
$section->slides()->delete();
|
||||
$label->songSlides()->delete();
|
||||
|
||||
// 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;
|
||||
foreach ($section->lines as $order => $line) {
|
||||
$translatedLine = $section->linesTranslated[$order] ?? null;
|
||||
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
|
||||
|
||||
$section->slides()->create([
|
||||
'order' => $slideOrder + 1,
|
||||
'text_content' => implode("\n", $chunk),
|
||||
SongSlide::create([
|
||||
'label_id' => $label->id,
|
||||
'order' => $order + 1,
|
||||
'text_content' => $line,
|
||||
'text_content_translated' => $translatedLine,
|
||||
]);
|
||||
}
|
||||
|
|
@ -120,15 +93,15 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
|
||||
SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete();
|
||||
|
||||
foreach ($sectionIds as $order => $sectionId) {
|
||||
foreach ($labelIds as $order => $labelId) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $sectionId,
|
||||
'label_id' => $labelId,
|
||||
'order' => $order + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
$song = $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'import',
|
||||
|
|
@ -164,32 +137,15 @@ 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' => $this->labelColor($canonicalKind), 'last_imported_at' => now()],
|
||||
['color' => self::DEFAULT_LABEL_COLOR, 'last_imported_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
private function labelColor(string $canonicalKind): string
|
||||
{
|
||||
if (array_key_exists($canonicalKind, self::LABEL_KIND_COLORS)) {
|
||||
return self::LABEL_KIND_COLORS[$canonicalKind];
|
||||
}
|
||||
|
||||
$colors = array_values(self::LABEL_KIND_COLORS);
|
||||
|
||||
return $colors[crc32($canonicalKind) % count($colors)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,30 +53,13 @@ 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;
|
||||
|
|
@ -111,21 +94,12 @@ public function parse(string $rawText): ParsedCcliSong
|
|||
'modifier' => $label['modifier'],
|
||||
'lines' => [],
|
||||
];
|
||||
$previousLineWasBlank = false;
|
||||
$currentParagraphLineCount = 0;
|
||||
$currentParagraphStartedAfterBlank = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current !== null) {
|
||||
if ($currentParagraphLineCount === 0) {
|
||||
$currentParagraphStartedAfterBlank = $previousLineWasBlank;
|
||||
}
|
||||
|
||||
$current['lines'][] = $line;
|
||||
$currentParagraphLineCount++;
|
||||
$previousLineWasBlank = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa
|
|||
{
|
||||
$parsed = $this->parser->parse($ccliRawText);
|
||||
|
||||
$localSong->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
$localSong->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
|
||||
|
||||
$arrangement = $this->findArrangement($localSong, $arrangementName);
|
||||
|
||||
|
|
@ -47,17 +47,16 @@ public function pair(Song $localSong, string $ccliRawText, string $arrangementNa
|
|||
$unmatchedLabels = [];
|
||||
$allDistributedLines = [];
|
||||
|
||||
foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
foreach ($arrangement->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($section === null || $label === null) {
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localCanonical = $this->canonicalLabel($label->name, null);
|
||||
$matchedSection = $ccliByCanonical[$localCanonical] ?? null;
|
||||
$slides = $section->slides->sortBy('order')->values();
|
||||
$slides = $label->songSlides->sortBy('order')->values();
|
||||
|
||||
if ($matchedSection === null) {
|
||||
$unmatchedLabels[] = $label->name;
|
||||
|
|
|
|||
|
|
@ -56,45 +56,6 @@ 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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -182,11 +143,11 @@ public function processZip(UploadedFile|string|SplFileInfo $file): array
|
|||
return $results;
|
||||
}
|
||||
|
||||
public function generateThumbnail(string $path, string $disk = 'public'): string
|
||||
public function generateThumbnail(string $path): string
|
||||
{
|
||||
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||
? $path
|
||||
: Storage::disk($disk)->path($path);
|
||||
: Storage::disk('public')->path($path);
|
||||
|
||||
if (! is_file($absolutePath)) {
|
||||
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
|
||||
|
|
@ -194,8 +155,8 @@ public function generateThumbnail(string $path, string $disk = 'public'): string
|
|||
|
||||
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
|
||||
$thumbnailRelativePath = 'slides/thumbnails/'.$filename;
|
||||
$thumbnailAbsolutePath = Storage::disk($disk)->path($thumbnailRelativePath);
|
||||
Storage::disk($disk)->makeDirectory('slides/thumbnails');
|
||||
$thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath);
|
||||
Storage::disk('public')->makeDirectory('slides/thumbnails');
|
||||
$this->ensureDirectory(dirname($thumbnailAbsolutePath));
|
||||
|
||||
$manager = $this->createImageManager();
|
||||
|
|
@ -318,20 +279,6 @@ 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)) {
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
<?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 !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -21,9 +21,8 @@ public function generatePlaylist(Service $service): array
|
|||
->orderBy('sort_order')
|
||||
->with([
|
||||
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
|
||||
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSong.arrangement.arrangementSections.section.label',
|
||||
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'serviceSong.arrangement.arrangementLabels.label',
|
||||
])
|
||||
->get();
|
||||
|
||||
|
|
@ -62,20 +61,7 @@ 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);
|
||||
|
|
@ -87,8 +73,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$tempDir,
|
||||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
$service,
|
||||
'information',
|
||||
);
|
||||
$announcementInserted = true;
|
||||
}
|
||||
|
|
@ -112,7 +96,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
rename($proPath, $destPath);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
|
|
@ -127,11 +110,6 @@ 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,
|
||||
|
|
@ -140,20 +118,6 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -168,8 +132,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$tempDir,
|
||||
$prependItems,
|
||||
$prependFiles,
|
||||
$service,
|
||||
'information',
|
||||
);
|
||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
||||
|
|
@ -203,12 +165,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
*/
|
||||
private function generatePlaylistLegacy(Service $service): array
|
||||
{
|
||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
|
||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
|
||||
|
||||
$matchedSongs = $service->serviceSongs()
|
||||
->whereNotNull('song_id')
|
||||
->orderBy('order')
|
||||
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
|
||||
->with('song.arrangements.arrangementLabels.label.songSlides')
|
||||
->get();
|
||||
|
||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||
|
|
@ -245,7 +207,6 @@ private function generatePlaylistLegacy(Service $service): array
|
|||
rename($proPath, $destPath);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
|
|
@ -299,13 +260,9 @@ 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);
|
||||
|
|
@ -320,22 +277,11 @@ private function addSlidesFromCollection(
|
|||
|
||||
$imageFiles[$imageFilename] = file_get_contents($destPath);
|
||||
|
||||
$singleSlideData = [
|
||||
$slideDataList[] = [
|
||||
'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)) {
|
||||
|
|
@ -416,120 +362,14 @@ 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()
|
||||
|
|
@ -538,140 +378,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
|
||||
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;
|
||||
|
|
@ -19,7 +17,6 @@ class ProBundleExportService
|
|||
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
private readonly ServiceImageResolver $imageResolver,
|
||||
) {}
|
||||
|
||||
public function generateBundle(Service $service, string $blockType): string
|
||||
|
|
@ -35,7 +32,7 @@ public function generateBundle(Service $service, string $blockType): string
|
|||
|
||||
$groupName = ucfirst($blockType);
|
||||
|
||||
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
|
||||
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType);
|
||||
}
|
||||
|
||||
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||
|
|
@ -43,8 +40,7 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
$agendaItem->loadMissing([
|
||||
'service',
|
||||
'slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
]);
|
||||
|
||||
$title = $agendaItem->title ?: 'Ablauf-Element';
|
||||
|
|
@ -64,10 +60,7 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
||||
$proFilename = self::safeFilename($song->title).'.pro';
|
||||
|
||||
$songMediaFiles = [];
|
||||
$this->embedBackground($agendaItem->service, $songMediaFiles);
|
||||
|
||||
$bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles);
|
||||
$bundle = new PresentationBundle($parserSong, $proFilename);
|
||||
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
|
||||
ProBundleWriter::write($bundle, $bundlePath);
|
||||
|
||||
|
|
@ -79,27 +72,14 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return $this->buildBundleFromSlides(
|
||||
$slides,
|
||||
$title,
|
||||
$agendaItem->service,
|
||||
'agenda_item',
|
||||
$this->backgroundPartTypeForAgendaItem($agendaItem),
|
||||
);
|
||||
return $this->buildBundleFromSlides($slides, $title, $agendaItem->service, 'agenda_item');
|
||||
}
|
||||
|
||||
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
||||
private function buildBundleFromSlides(
|
||||
$slides,
|
||||
string $groupName,
|
||||
?Service $service = null,
|
||||
?string $partType = null,
|
||||
?string $backgroundPartType = null,
|
||||
): string {
|
||||
private function buildBundleFromSlides($slides, string $groupName, ?Service $service = null, ?string $partType = null): string
|
||||
{
|
||||
$slideData = [];
|
||||
$mediaFiles = [];
|
||||
$background = $this->backgroundData($service);
|
||||
$backgroundAttached = false;
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
||||
|
|
@ -121,11 +101,6 @@ private function buildBundleFromSlides(
|
|||
'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();
|
||||
|
|
@ -144,10 +119,6 @@ private function buildBundleFromSlides(
|
|||
$slideData[] = $singleSlideData;
|
||||
}
|
||||
|
||||
if ($backgroundAttached) {
|
||||
$this->embedBackground($service, $mediaFiles);
|
||||
}
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'name' => $groupName,
|
||||
|
|
@ -173,83 +144,6 @@ private function buildBundleFromSlides(
|
|||
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';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ class ProExportService
|
|||
{
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
private readonly ServiceImageResolver $imageResolver,
|
||||
) {}
|
||||
|
||||
public function generateProFile(Song $song, ?Service $service = null): string
|
||||
|
|
@ -30,7 +29,7 @@ public function generateProFile(Song $song, ?Service $service = null): string
|
|||
|
||||
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
|
||||
{
|
||||
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
$song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
|
||||
|
||||
return ProFileGenerator::generate(
|
||||
$song->title,
|
||||
|
|
@ -48,40 +47,34 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
return [];
|
||||
}
|
||||
|
||||
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
|
||||
$defaultArr->loadMissing('arrangementLabels.label.songSlides');
|
||||
|
||||
$groups = [];
|
||||
$seenSectionIds = [];
|
||||
$background = $this->backgroundData($service);
|
||||
$seenLabelIds = [];
|
||||
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($section === null || $label === null) {
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($section->id, $seenSectionIds, true)) {
|
||||
if (in_array($label->id, $seenLabelIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$seenSectionIds[] = $section->id;
|
||||
$seenLabelIds[] = $label->id;
|
||||
|
||||
$slides = [];
|
||||
$sectionSlides = $section->slides->sortBy('order')->values();
|
||||
$totalSlides = $sectionSlides->count();
|
||||
$labelSlides = $label->songSlides->sortBy('order')->values();
|
||||
$totalSlides = $labelSlides->count();
|
||||
|
||||
foreach ($sectionSlides as $slideIndex => $slide) {
|
||||
foreach ($labelSlides 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,
|
||||
|
|
@ -108,46 +101,16 @@ 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('arrangementSections.section.label');
|
||||
$arrangement->loadMissing('arrangementLabels.label');
|
||||
|
||||
$groupNames = $arrangement->arrangementSections
|
||||
$groupNames = $arrangement->arrangementLabels
|
||||
->sortBy('order')
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
||||
->map(fn ($al) => $al->label?->name)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
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;
|
||||
|
|
@ -105,14 +104,14 @@ private function upsertSong(ProSong $proSong): Song
|
|||
}
|
||||
|
||||
$song->arrangements()->each(function (SongArrangement $arr) {
|
||||
$arr->arrangementSections()->delete();
|
||||
$arr->arrangementLabels()->delete();
|
||||
});
|
||||
$song->arrangements()->delete();
|
||||
|
||||
$hasTranslation = false;
|
||||
$sectionsByName = [];
|
||||
$labelsByName = [];
|
||||
|
||||
foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
|
||||
foreach ($proSong->getGroups() as $proGroup) {
|
||||
$groupName = $proGroup->getName();
|
||||
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
|
||||
|
||||
|
|
@ -126,14 +125,9 @@ private function upsertSong(ProSong $proSong): Song
|
|||
]);
|
||||
}
|
||||
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $existingLabel->id],
|
||||
['order' => $groupOrder + 1],
|
||||
);
|
||||
$section->update(['order' => $groupOrder + 1]);
|
||||
$sectionsByName[$groupName] = $section;
|
||||
$labelsByName[$groupName] = $existingLabel;
|
||||
|
||||
$section->slides()->delete();
|
||||
$existingLabel->songSlides()->delete();
|
||||
|
||||
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
|
||||
$translatedText = null;
|
||||
|
|
@ -143,7 +137,7 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$hasTranslation = true;
|
||||
}
|
||||
|
||||
$section->slides()->create([
|
||||
$existingLabel->songSlides()->create([
|
||||
'order' => $slidePosition,
|
||||
'text_content' => $proSlide->getPlainText(),
|
||||
'text_content_translated' => $translatedText,
|
||||
|
|
@ -162,19 +156,19 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
|
||||
|
||||
foreach ($groupsInArrangement as $order => $proGroup) {
|
||||
$section = $sectionsByName[$proGroup->getName()] ?? null;
|
||||
$label = $labelsByName[$proGroup->getName()] ?? null;
|
||||
|
||||
if ($section) {
|
||||
if ($label) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
return $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
|
||||
}
|
||||
|
||||
public static function rgbaToHex(array $rgba): string
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,16 +6,15 @@
|
|||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongService
|
||||
{
|
||||
/**
|
||||
* Sicherstellen, dass die Default-Labels und Song-Sektionen existieren.
|
||||
* Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren.
|
||||
*
|
||||
* @return Collection<int, SongSection>
|
||||
* @return Collection<int, Label>
|
||||
*/
|
||||
public function createDefaultGroups(Song $song): Collection
|
||||
{
|
||||
|
|
@ -25,9 +24,9 @@ public function createDefaultGroups(Song $song): Collection
|
|||
['name' => 'Bridge', 'color' => '#F59E0B'],
|
||||
];
|
||||
|
||||
$sections = collect();
|
||||
$labels = collect();
|
||||
|
||||
foreach ($defaults as $index => $data) {
|
||||
foreach ($defaults as $data) {
|
||||
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
|
||||
|
||||
if ($existing === null) {
|
||||
|
|
@ -37,16 +36,10 @@ public function createDefaultGroups(Song $song): Collection
|
|||
]);
|
||||
}
|
||||
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $existing->id],
|
||||
['order' => $index + 1],
|
||||
);
|
||||
$section->update(['order' => $index + 1]);
|
||||
|
||||
$sections->push($section);
|
||||
$labels->push($existing);
|
||||
}
|
||||
|
||||
return $sections;
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,16 +52,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
|
|||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$sections = $this->createDefaultGroups($song);
|
||||
$labels = $this->createDefaultGroups($song);
|
||||
|
||||
foreach ($sections->values() as $index => $section) {
|
||||
$arrangement->arrangementSections()->create([
|
||||
'song_section_id' => $section->id,
|
||||
foreach ($labels->values() as $index => $label) {
|
||||
$arrangement->arrangementLabels()->create([
|
||||
'label_id' => $label->id,
|
||||
'order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $arrangement->load('arrangementSections.section.label');
|
||||
return $arrangement->load('arrangementLabels.label');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,15 +75,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
|
|||
$clone->is_default = false;
|
||||
$clone->save();
|
||||
|
||||
foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
|
||||
foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $clone->id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $arrangementSection->order,
|
||||
'label_id' => $arrangementLabel->label_id,
|
||||
'order' => $arrangementLabel->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return $clone->load('arrangementSections.section.label');
|
||||
return $clone->load('arrangementLabels.label');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function importTranslation(Song $song, string $text): void
|
|||
|
||||
$defaultArr = $song->arrangements()
|
||||
->where('is_default', true)
|
||||
->with(['arrangementSections' => fn ($q) => $q->orderBy('order'), 'arrangementSections.section.slides'])
|
||||
->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides'])
|
||||
->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
|
|
@ -43,14 +43,14 @@ public function importTranslation(Song $song, string $text): void
|
|||
return;
|
||||
}
|
||||
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($section === null) {
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($section->slides->sortBy('order') as $slide) {
|
||||
foreach ($label->songSlides->sortBy('order') as $slide) {
|
||||
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
|
||||
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
||||
$offset += $originalLineCount;
|
||||
|
|
@ -71,13 +71,15 @@ public function markAsTranslated(Song $song): void
|
|||
|
||||
public function removeTranslation(Song $song): void
|
||||
{
|
||||
$sectionIds = $song->sections()
|
||||
->pluck('id')
|
||||
$labelIds = $song->arrangements()
|
||||
->with('arrangementLabels')
|
||||
->get()
|
||||
->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($sectionIds->isNotEmpty()) {
|
||||
SongSlide::whereIn('song_section_id', $sectionIds)->update([
|
||||
if ($labelIds->isNotEmpty()) {
|
||||
SongSlide::whereIn('label_id', $labelIds)->update([
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ final class CcliLabels
|
|||
/**
|
||||
* Regex matching CCLI SongSelect section labels (English + German + variants).
|
||||
*/
|
||||
public const SECTION_LABEL_PATTERN = '/^(Verse|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';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Regex matching CCLI footer/metadata lines (copyright, CCLI number).
|
||||
|
|
@ -18,7 +18,6 @@ final class CcliLabels
|
|||
* Bidirectional English ↔ German label kind mapping.
|
||||
*/
|
||||
public const LABEL_NAME_MAP = [
|
||||
'Vers' => 'Verse',
|
||||
'Strophe' => 'Verse',
|
||||
'Refrain' => 'Chorus',
|
||||
'Brücke' => 'Bridge',
|
||||
|
|
@ -54,7 +53,7 @@ public static function normalizeLabelName(string $label): string
|
|||
{
|
||||
$trimmed = trim($label);
|
||||
|
||||
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)) {
|
||||
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)) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ public static function parseLabel(string $line): ?array
|
|||
{
|
||||
$trimmed = trim($line);
|
||||
|
||||
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)) {
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
4
database/factories/SongArrangementLabelFactory.php
Executable file → Normal file
4
database/factories/SongArrangementLabelFactory.php
Executable file → Normal 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(),
|
||||
'song_section_id' => SongSection::factory(),
|
||||
'label_id' => Label::factory(),
|
||||
'order' => $this->faker->numberBetween(0, 10),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?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
Executable file → Normal file
4
database/factories/SongSlideFactory.php
Executable file → Normal file
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SongSection;
|
||||
use App\Models\Label;
|
||||
use App\Models\SongSlide;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ class SongSlideFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'song_section_id' => SongSection::factory(),
|
||||
'label_id' => Label::factory(),
|
||||
'order' => $this->faker->numberBetween(1, 12),
|
||||
'text_content' => implode("\n", $this->faker->sentences(3)),
|
||||
'text_content_translated' => $this->faker->optional()->sentence(),
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('song_sections', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('song_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('label_id')->constrained('labels')->restrictOnDelete();
|
||||
$table->unsignedInteger('order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['song_id', 'label_id']);
|
||||
});
|
||||
|
||||
Schema::table('song_slides', function (Blueprint $table): void {
|
||||
$table->foreignId('song_section_id')->nullable()->after('id')->constrained('song_sections')->cascadeOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
|
||||
$table->foreignId('song_section_id')->nullable()->after('label_id')->constrained('song_sections')->cascadeOnDelete();
|
||||
});
|
||||
|
||||
$this->backfillSections();
|
||||
$this->backfillSlides();
|
||||
$this->backfillArrangementSections();
|
||||
|
||||
DB::table('song_slides')->whereNull('song_section_id')->delete();
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
$this->finalizeSqliteTables();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
Schema::table('song_slides', function (Blueprint $table): void {
|
||||
$table->dropForeign(['label_id']);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('song_slides', function (Blueprint $table): void {
|
||||
$table->dropColumn('label_id');
|
||||
});
|
||||
|
||||
Schema::table('song_slides', function (Blueprint $table): void {
|
||||
$table->foreignId('song_section_id')->nullable(false)->change();
|
||||
});
|
||||
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
|
||||
$table->dropForeign(['label_id']);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
|
||||
$table->dropColumn('label_id');
|
||||
});
|
||||
|
||||
Schema::table('song_arrangement_labels', function (Blueprint $table): void {
|
||||
$table->foreignId('song_section_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException('Destruktive Migration: kein Rollback. Backup einspielen.');
|
||||
}
|
||||
|
||||
private function backfillSections(): void
|
||||
{
|
||||
DB::table('song_arrangements')
|
||||
->select('song_arrangements.song_id')
|
||||
->join('song_arrangement_labels', 'song_arrangement_labels.song_arrangement_id', '=', 'song_arrangements.id')
|
||||
->whereNotNull('song_arrangement_labels.label_id')
|
||||
->distinct()
|
||||
->orderBy('song_arrangements.song_id')
|
||||
->chunk(100, function ($songs): void {
|
||||
foreach ($songs as $song) {
|
||||
$this->backfillSectionsForSong((int) $song->song_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillSectionsForSong(int $songId): void
|
||||
{
|
||||
$labelIds = DB::table('song_arrangement_labels')
|
||||
->join('song_arrangements', 'song_arrangements.id', '=', 'song_arrangement_labels.song_arrangement_id')
|
||||
->where('song_arrangements.song_id', $songId)
|
||||
->whereNotNull('song_arrangement_labels.label_id')
|
||||
->distinct()
|
||||
->orderBy('song_arrangement_labels.label_id')
|
||||
->pluck('song_arrangement_labels.label_id')
|
||||
->map(fn ($labelId): int => (int) $labelId)
|
||||
->all();
|
||||
|
||||
if ($labelIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preferredArrangementId = DB::table('song_arrangements')
|
||||
->where('song_id', $songId)
|
||||
->orderByDesc('is_default')
|
||||
->orderByRaw("lower(name) = 'normal' desc")
|
||||
->orderBy('id')
|
||||
->value('id');
|
||||
|
||||
$preferredOrders = collect();
|
||||
|
||||
if ($preferredArrangementId !== null) {
|
||||
$preferredOrders = DB::table('song_arrangement_labels')
|
||||
->select('label_id', DB::raw('min("order") as section_order'))
|
||||
->where('song_arrangement_id', $preferredArrangementId)
|
||||
->whereIn('label_id', $labelIds)
|
||||
->groupBy('label_id')
|
||||
->pluck('section_order', 'label_id');
|
||||
}
|
||||
|
||||
$fallbackOrder = ((int) $preferredOrders->max()) + 1;
|
||||
$now = now();
|
||||
$rows = [];
|
||||
|
||||
foreach ($labelIds as $labelId) {
|
||||
$rows[] = [
|
||||
'song_id' => $songId,
|
||||
'label_id' => $labelId,
|
||||
'order' => $preferredOrders->has($labelId) ? (int) $preferredOrders->get($labelId) : $fallbackOrder++,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('song_sections')->insertOrIgnore($rows);
|
||||
}
|
||||
|
||||
private function backfillSlides(): void
|
||||
{
|
||||
DB::table('song_sections')
|
||||
->select(['id', 'label_id'])
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($sections): void {
|
||||
foreach ($sections as $section) {
|
||||
$this->backfillSlidesForSection((int) $section->id, (int) $section->label_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillSlidesForSection(int $sectionId, int $labelId): void
|
||||
{
|
||||
DB::table('song_slides')
|
||||
->where('label_id', $labelId)
|
||||
->whereNull('song_section_id')
|
||||
->orderBy('order')
|
||||
->chunkById(200, function ($slides) use ($sectionId): void {
|
||||
$now = now();
|
||||
$rows = [];
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$rows[] = [
|
||||
'label_id' => $slide->label_id,
|
||||
'song_section_id' => $sectionId,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table('song_slides')->insert($rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillArrangementSections(): void
|
||||
{
|
||||
DB::table('song_arrangement_labels')
|
||||
->select([
|
||||
'song_arrangement_labels.id',
|
||||
'song_arrangement_labels.label_id',
|
||||
'song_arrangements.song_id',
|
||||
])
|
||||
->join('song_arrangements', 'song_arrangements.id', '=', 'song_arrangement_labels.song_arrangement_id')
|
||||
->whereNotNull('song_arrangement_labels.label_id')
|
||||
->orderBy('song_arrangement_labels.id')
|
||||
->chunkById(500, function ($arrangementLabels): void {
|
||||
foreach ($arrangementLabels as $arrangementLabel) {
|
||||
$sectionId = DB::table('song_sections')
|
||||
->where('song_id', $arrangementLabel->song_id)
|
||||
->where('label_id', $arrangementLabel->label_id)
|
||||
->value('id');
|
||||
|
||||
if ($sectionId !== null) {
|
||||
DB::table('song_arrangement_labels')
|
||||
->where('id', $arrangementLabel->id)
|
||||
->update(['song_section_id' => $sectionId]);
|
||||
}
|
||||
}
|
||||
}, 'song_arrangement_labels.id', 'id');
|
||||
}
|
||||
|
||||
private function finalizeSqliteTables(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::create('song_slides_new', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('song_section_id')->constrained('song_sections')->cascadeOnDelete();
|
||||
$table->unsignedInteger('order');
|
||||
$table->text('text_content');
|
||||
$table->text('text_content_translated')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
DB::statement('insert into song_slides_new (id, song_section_id, "order", text_content, text_content_translated, notes, created_at, updated_at) select id, song_section_id, "order", text_content, text_content_translated, notes, created_at, updated_at from song_slides');
|
||||
|
||||
Schema::drop('song_slides');
|
||||
Schema::rename('song_slides_new', 'song_slides');
|
||||
|
||||
Schema::create('song_arrangement_labels_new', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('song_arrangement_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('song_section_id')->constrained('song_sections')->cascadeOnDelete();
|
||||
$table->unsignedInteger('order');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['song_arrangement_id', 'order']);
|
||||
});
|
||||
|
||||
DB::statement('insert into song_arrangement_labels_new (id, song_arrangement_id, song_section_id, "order", created_at, updated_at) select id, song_arrangement_id, song_section_id, "order", created_at, updated_at from song_arrangement_labels');
|
||||
|
||||
Schema::drop('song_arrangement_labels');
|
||||
Schema::rename('song_arrangement_labels_new', 'song_arrangement_labels');
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
520
package-lock.json
generated
520
package-lock.json
generated
|
|
@ -28,9 +28,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -38,9 +38,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"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==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -48,13 +48,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"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==",
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
|
|
@ -64,14 +64,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -520,9 +520,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@inertiajs/core": {
|
||||
"version": "2.3.24",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.24.tgz",
|
||||
"integrity": "sha512-xAlUl5+RKtdbutEgsmdWa6HmnvjIGcWTrvfLj/3Icy3/7bSH3aiI+kuYPs17LBq/SMaXnqBZXXo094rEXUv2aA==",
|
||||
"version": "2.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.23.tgz",
|
||||
"integrity": "sha512-d+jcdf91RY5bjT+ivBn2gu9Qsmxx5CacvVmVp7o9H2Lu/k1cBD0m4UZB5YKWfG11aBCucShBxuppEXme7sSEow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -534,13 +534,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@inertiajs/vue3": {
|
||||
"version": "2.3.24",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.24.tgz",
|
||||
"integrity": "sha512-TokM+JU88YTHClh/LcKk31qiIAZFq3RQ4BBf1dxvk6MV45KWYemJMpLS6WFJ5NaSv6rZFlZrRc92N0ZdyOC/HA==",
|
||||
"version": "2.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.23.tgz",
|
||||
"integrity": "sha512-W6rhjNCIKQQYZsNFdlFEa+prATbXcIR5k9VMg3PsYfRmqTYTORZspdrTH7JLPMneLuhNiI/Y6VbFR0TxGdv5Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.24",
|
||||
"@inertiajs/core": "2.3.23",
|
||||
"@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.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -633,16 +633,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -654,9 +654,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -668,9 +668,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -682,9 +682,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -696,9 +696,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -710,9 +710,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -724,9 +724,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -738,9 +738,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -752,9 +752,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -766,9 +766,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -780,9 +780,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -794,9 +794,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -808,9 +808,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -822,9 +822,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -836,9 +836,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -850,9 +850,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -864,9 +864,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -878,9 +878,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -892,9 +892,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -906,9 +906,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -920,9 +920,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"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==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -934,9 +934,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -948,9 +948,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -962,9 +962,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -976,9 +976,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1313,13 +1313,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"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==",
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
|
||||
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
|
@ -1330,111 +1330,111 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
|
||||
"integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
||||
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/shared": "3.5.35",
|
||||
"@vue/shared": "3.5.34",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
|
||||
"integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
|
||||
"integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
|
||||
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/compiler-core": "3.5.35",
|
||||
"@vue/compiler-dom": "3.5.35",
|
||||
"@vue/compiler-ssr": "3.5.35",
|
||||
"@vue/shared": "3.5.35",
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/compiler-ssr": "3.5.34",
|
||||
"@vue/shared": "3.5.34",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss": "^8.5.14",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
|
||||
"integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
|
||||
"integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
|
||||
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.35"
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
|
||||
"integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
|
||||
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
"@vue/reactivity": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
|
||||
"integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
|
||||
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.35",
|
||||
"@vue/runtime-core": "3.5.35",
|
||||
"@vue/shared": "3.5.35",
|
||||
"@vue/reactivity": "3.5.34",
|
||||
"@vue/runtime-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
|
||||
"integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
|
||||
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.35",
|
||||
"@vue/shared": "3.5.35"
|
||||
"@vue/compiler-ssr": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.35"
|
||||
"vue": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
|
||||
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
||||
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -1477,19 +1477,6 @@
|
|||
"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",
|
||||
|
|
@ -1561,22 +1548,21 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"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.33",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -1652,9 +1638,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1782,24 +1768,6 @@
|
|||
"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",
|
||||
|
|
@ -1836,9 +1804,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"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==",
|
||||
"version": "1.5.353",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
|
||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -1850,9 +1818,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.22.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
|
||||
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
|
||||
"version": "5.21.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz",
|
||||
"integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1897,9 +1865,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2188,9 +2156,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"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==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2200,20 +2168,6 @@
|
|||
"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",
|
||||
|
|
@ -2586,13 +2540,6 @@
|
|||
"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",
|
||||
|
|
@ -2613,14 +2560,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
|
|
@ -2656,13 +2600,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -2675,9 +2619,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -2688,9 +2632,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2708,7 +2652,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -2734,9 +2678,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
|
@ -2772,9 +2716,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2788,31 +2732,31 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -2991,9 +2935,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3170,17 +3114,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
|
||||
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
||||
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ function saveArrangement() {
|
|||
`/arrangements/${selectedArrangement.value.id}`,
|
||||
{
|
||||
groups: arrangementGroups.value.map((group, index) => ({
|
||||
section_id: group.section_id ?? group.id,
|
||||
label_id: group.id,
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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'
|
||||
|
||||
|
|
@ -28,14 +27,6 @@ const props = defineProps({
|
|||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
serviceSongName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
serviceSongCcliId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
songsCatalog: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
|
|
@ -48,12 +39,11 @@ const emit = defineEmits(['close', 'arrangement-selected'])
|
|||
|
||||
const isUnmatched = computed(() => !props.songId)
|
||||
|
||||
const searchQuery = ref(props.serviceSongName ?? '')
|
||||
const searchQuery = ref('')
|
||||
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()
|
||||
|
|
@ -67,23 +57,6 @@ 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
|
||||
}
|
||||
|
|
@ -275,11 +248,6 @@ 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(() => {
|
||||
|
|
@ -562,19 +530,10 @@ function closeOnBackdrop(e) {
|
|||
v-for="song in filteredCatalog"
|
||||
:key="song.id"
|
||||
type="button"
|
||||
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'"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm 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>
|
||||
|
|
@ -591,14 +550,16 @@ function closeOnBackdrop(e) {
|
|||
>
|
||||
Zuordnen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<a
|
||||
v-if="searchQuery"
|
||||
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(searchQuery)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 ↗
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-ccli-paste-dialog-button"
|
||||
|
|
@ -804,15 +765,6 @@ function closeOnBackdrop(e) {
|
|||
:service-song-id="props.serviceSongId"
|
||||
@close="ccliDialogOpen = false"
|
||||
@imported="(songId) => { ccliDialogOpen = false; router.reload({ only: ['service'] }) }"
|
||||
@edit-song="(id) => { ccliDialogOpen = false; editSongId = id }"
|
||||
/>
|
||||
|
||||
<!-- Song Edit Modal -->
|
||||
<SongEditModal
|
||||
:show="editSongId !== null"
|
||||
:song-id="editSongId"
|
||||
@close="editSongId = null"
|
||||
@updated="() => router.reload({ only: ['service'] })"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const props = defineProps({
|
|||
prefilledText: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'imported', 'paired', 'edit-song'])
|
||||
const emit = defineEmits(['close', 'imported', 'paired'])
|
||||
|
||||
const pasteText = ref('')
|
||||
const preview = ref(null)
|
||||
|
|
@ -94,8 +94,7 @@ async function doImport(importMode) {
|
|||
}
|
||||
|
||||
if (importMode === 'edit') {
|
||||
emit('edit-song', data.song_id)
|
||||
emit('close')
|
||||
router.visit('/songs/' + data.song_id)
|
||||
} else if (importMode === 'pair') {
|
||||
router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true')
|
||||
} else {
|
||||
|
|
@ -111,8 +110,7 @@ async function doImport(importMode) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div v-if="open" class="fixed inset-0 z-50 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">
|
||||
|
|
@ -133,8 +131,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>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>
|
||||
<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>
|
||||
</ol>
|
||||
|
||||
<!-- Textarea -->
|
||||
|
|
@ -170,13 +168,12 @@ async function doImport(importMode) {
|
|||
class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700"
|
||||
>
|
||||
{{ error }}
|
||||
<button
|
||||
<a
|
||||
v-if="existingSongId"
|
||||
type="button"
|
||||
@click="emit('edit-song', existingSongId); emit('close')"
|
||||
:href="'/songs/' + existingSongId"
|
||||
data-testid="ccli-existing-song-link"
|
||||
class="ml-2 underline font-medium"
|
||||
>Vorhandenen Song bearbeiten</button>
|
||||
>Vorhandenen Song bearbeiten</a>
|
||||
</div>
|
||||
|
||||
<!-- Preview pane -->
|
||||
|
|
@ -187,21 +184,8 @@ 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="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 class="text-xs text-gray-500">
|
||||
Sektionen: {{ preview.sections?.map(s => s.label).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -261,5 +245,4 @@ async function doImport(importMode) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -21,78 +21,17 @@ 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
|
||||
|
||||
|
|
@ -114,7 +53,6 @@ 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 ?? ''
|
||||
|
|
@ -135,11 +73,7 @@ watch(
|
|||
|
||||
if (!isVisible) {
|
||||
songData.value = null
|
||||
sectionDrafts.value = {}
|
||||
error.value = null
|
||||
showAddSectionForm.value = false
|
||||
newSectionLabel.value = ''
|
||||
newSectionText.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -149,7 +83,8 @@ watch(
|
|||
const performSave = async (data) => {
|
||||
if (!props.songId) return
|
||||
|
||||
startSaving()
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
|
|
@ -170,11 +105,17 @@ const performSave = async (data) => {
|
|||
throw new Error('Speichern fehlgeschlagen')
|
||||
}
|
||||
|
||||
finishSaving()
|
||||
saving.value = false
|
||||
saved.value = true
|
||||
|
||||
if (savedTimeout) clearTimeout(savedTimeout)
|
||||
savedTimeout = setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
|
||||
emit('updated')
|
||||
} catch {
|
||||
stopSaving()
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,12 +150,10 @@ 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.section_id ?? ag.label_id))
|
||||
const group = songData.value.groups.find((g) => g.id === ag.label_id)
|
||||
|
||||
return {
|
||||
id: ag.section_id ?? ag.label_id,
|
||||
section_id: ag.section_id ?? ag.label_id,
|
||||
label_id: ag.label_id,
|
||||
id: ag.label_id,
|
||||
name: group?.name ?? 'Unbekannt',
|
||||
color: group?.color ?? '#6b7280',
|
||||
order: ag.order,
|
||||
|
|
@ -228,191 +167,11 @@ 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) => {
|
||||
|
|
@ -722,177 +481,6 @@ 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">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, router, usePage } from '@inertiajs/vue3'
|
||||
|
||||
const $page = usePage()
|
||||
import { Head, router } from '@inertiajs/vue3'
|
||||
import { ref, computed } from 'vue'
|
||||
import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
|
||||
import AgendaItemRow from '@/Components/AgendaItemRow.vue'
|
||||
|
|
@ -10,7 +8,6 @@ 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: {
|
||||
|
|
@ -98,12 +95,6 @@ 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' })
|
||||
}
|
||||
|
|
@ -388,66 +379,6 @@ 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">
|
||||
|
|
@ -584,8 +515,6 @@ 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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ const submenus = [
|
|||
{ key: 'labels', label: 'Label-Import' },
|
||||
{ key: 'agenda', label: 'Agenda' },
|
||||
{ key: 'ccli', label: 'CCLI Import' },
|
||||
{ key: 'namenseinblender', label: 'Namenseinblender' },
|
||||
]
|
||||
|
||||
const activeSubmenu = ref('assignments')
|
||||
|
|
@ -218,65 +217,11 @@ 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, 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.
|
||||
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.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
<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 },
|
||||
|
|
@ -11,8 +9,6 @@ const props = defineProps({
|
|||
prefillError: { type: String, default: null },
|
||||
})
|
||||
|
||||
const editSongId = ref(null)
|
||||
|
||||
function handleClose() {
|
||||
router.visit(route('songs.index'))
|
||||
}
|
||||
|
|
@ -53,13 +49,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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)
|
||||
|
|
@ -39,7 +38,6 @@ 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',
|
||||
|
|
@ -63,8 +61,6 @@ watch(search, () => {
|
|||
debounceTimer = setTimeout(() => fetchSongs(1), 500)
|
||||
})
|
||||
|
||||
watch(onlyWithContent, () => fetchSongs(1))
|
||||
|
||||
onMounted(() => fetchSongs())
|
||||
|
||||
function goToPage(page) {
|
||||
|
|
@ -72,19 +68,6 @@ 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', {
|
||||
|
|
@ -408,15 +391,16 @@ function pageRange() {
|
|||
|
||||
<!-- CCLI Import Buttons -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<button
|
||||
<a
|
||||
v-if="search"
|
||||
type="button"
|
||||
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(search)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 ↗
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-ccli-paste-dialog-button-songdb"
|
||||
|
|
@ -425,22 +409,6 @@ 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 -->
|
||||
|
|
@ -493,26 +461,12 @@ function pageRange() {
|
|||
<tr
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
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'"
|
||||
class="group transition-colors 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 -->
|
||||
|
|
@ -835,6 +789,5 @@ 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>
|
||||
|
|
|
|||
|
|
@ -121,18 +121,8 @@ 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 = stripSectionLabelLines(normalizeNewlines(text).split('\n'))
|
||||
const translatedLines = normalizeNewlines(text).split('\n')
|
||||
let offset = 0
|
||||
|
||||
orderedSlides().forEach((slide) => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,9 @@
|
|||
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;
|
||||
|
|
@ -69,9 +67,6 @@
|
|||
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 () {
|
||||
|
|
@ -91,9 +86,6 @@
|
|||
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
|
||||
Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy');
|
||||
Route::post('/songs/{song}/sections', [SongSectionController::class, 'store'])->name('songs.sections.store');
|
||||
Route::patch('/songs/{song}/sections/{section}', [SongSectionController::class, 'update'])->name('songs.sections.update');
|
||||
Route::delete('/songs/{song}/sections/{section}', [SongSectionController::class, 'destroy'])->name('songs.sections.destroy');
|
||||
|
||||
Route::get('/songs/{song}/arrangements/{arrangement}/pdf', [SongPdfController::class, 'download'])->name('songs.pdf');
|
||||
Route::get('/songs/{song}/arrangements/{arrangement}/preview', [SongPdfController::class, 'preview'])->name('songs.preview');
|
||||
|
|
|
|||
|
|
@ -105,8 +105,7 @@ 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']);
|
||||
$section = songSectionFor($song, $label);
|
||||
SongSlide::factory()->create(['song_section_id' => $section->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
|
||||
SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -114,7 +113,7 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
|
|||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
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;
|
||||
|
|
@ -38,13 +37,13 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
|
|||
$defaultLabelOrder = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_section_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$newLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $newArrangement->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_section_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($defaultLabelOrder, $newLabels);
|
||||
|
|
@ -73,13 +72,13 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups():
|
|||
$originalLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_section_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$cloneLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $clone->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_section_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($originalLabels, $cloneLabels);
|
||||
|
|
@ -93,10 +92,10 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
|
|||
|
||||
$response = $this->put(route('arrangements.update', $normal), [
|
||||
'groups' => [
|
||||
['section_id' => $chorus->id, 'order' => 1],
|
||||
['section_id' => $bridge->id, 'order' => 2],
|
||||
['section_id' => $verse->id, 'order' => 3],
|
||||
['section_id' => $chorus->id, 'order' => 4],
|
||||
['label_id' => $chorus->id, 'order' => 1],
|
||||
['label_id' => $bridge->id, 'order' => 2],
|
||||
['label_id' => $verse->id, 'order' => 3],
|
||||
['label_id' => $chorus->id, 'order' => 4],
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
|
|||
$updated = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_section_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
|
|
@ -137,13 +136,9 @@ private function createSongWithDefaultArrangement(): array
|
|||
{
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$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]);
|
||||
$verse = Label::factory()->create(['name' => 'Verse 1']);
|
||||
$chorus = Label::factory()->create(['name' => 'Chorus']);
|
||||
$bridge = Label::factory()->create(['name' => 'Bridge']);
|
||||
|
||||
$normal = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -153,19 +148,19 @@ private function createSongWithDefaultArrangement(): array
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_section_id' => $verse->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_section_id' => $chorus->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_section_id' => $verse->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,65 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class BookmarkletControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
public function test_bookmarklet_endpoint_returns_200_with_text_javascript_content_type(): void
|
||||
{
|
||||
test('bookmarklet endpoint returns 200 with text/javascript content type', function () {
|
||||
$response = $this->get('/bookmarklets/ccli-import.js');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
|
||||
}
|
||||
});
|
||||
|
||||
public function test_bookmarklet_response_starts_with_javascript_prefix(): void
|
||||
{
|
||||
test('bookmarklet response starts with javascript: prefix', function () {
|
||||
$response = $this->get('/bookmarklets/ccli-import.js');
|
||||
|
||||
expect($response->getContent())->toStartWith('javascript:');
|
||||
}
|
||||
});
|
||||
|
||||
public function test_bookmarklet_response_is_a_single_line_with_no_actual_newlines(): void
|
||||
{
|
||||
test('bookmarklet response is a single line with no actual newlines', function () {
|
||||
$response = $this->get('/bookmarklets/ccli-import.js');
|
||||
$content = (string) $response->getContent();
|
||||
$content = $response->getContent();
|
||||
|
||||
expect(substr_count($content, "\n"))->toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
public function test_bookmarklet_response_contains_app_url_and_import_path(): void
|
||||
{
|
||||
test('bookmarklet response contains app URL and import path', function () {
|
||||
$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');
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
test('bookmarklet endpoint does not require authentication', function () {
|
||||
$response = $this->get('/bookmarklets/ccli-import.js');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
use App\Exceptions\DuplicateCcliSongException;
|
||||
use App\Models\ApiRequestLog;
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSlide;
|
||||
|
|
@ -45,7 +44,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(5);
|
||||
->and(SongSlide::count())->toBe(9);
|
||||
});
|
||||
|
||||
test('imports english and german fixture and stores translated slide text', function () {
|
||||
|
|
@ -56,8 +55,8 @@ function ccliFixture(string $name): string
|
|||
|
||||
expect($result['status'])->toBe('created')
|
||||
->and($song->has_translation)->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();
|
||||
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(4)
|
||||
->and(SongSlide::where('text_content_translated', 'Deutsche Liedzeile 1 zum gleichen Gedanken')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('blocks active duplicate ccli_id with DuplicateCcliSongException', function () {
|
||||
|
|
@ -77,39 +76,6 @@ 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'));
|
||||
|
|
|
|||
|
|
@ -74,28 +74,6 @@ function ccliFixtureContent(string $filename): string
|
|||
expect($kinds)->toContain('Chorus');
|
||||
});
|
||||
|
||||
test('copy-icon-vers-author-trailing.txt parses SongSelect copy icon format', function (): void {
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('copy-icon-vers-author-trailing.txt'));
|
||||
|
||||
expect($result->title)->toBe('Heilig ist der Herr')
|
||||
->and($result->author)->toBe('Albert Frey')
|
||||
->and($result->ccliId)->toBe('4327499')
|
||||
->and($result->year)->toBe('1998')
|
||||
->and($result->sections)->toHaveCount(2);
|
||||
|
||||
$verse = $result->sections[0];
|
||||
$chorus = $result->sections[1];
|
||||
|
||||
expect($verse->label)->toBe('Vers')
|
||||
->and($verse->kind)->toBe('Verse')
|
||||
->and($verse->lines)->toHaveCount(9)
|
||||
->and($chorus->label)->toBe('Chorus')
|
||||
->and($chorus->kind)->toBe('Chorus')
|
||||
->and($chorus->lines)->toHaveCount(3)
|
||||
->and($chorus->lines)->not->toContain('Albert Frey');
|
||||
});
|
||||
|
||||
test('common CCLI metadata formats extract the song ID but not license numbers', function (): void {
|
||||
$parser = new CcliPasteParser;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
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;
|
||||
|
|
@ -27,21 +26,16 @@ 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,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => $order + 1,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < $slideCount; $i++) {
|
||||
SongSlide::create([
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => $i + 1,
|
||||
'text_content' => "Original line $i for $labelName",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
<?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');
|
||||
});
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,25 +2,18 @@
|
|||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
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();
|
||||
test('song_slides has label_id column after migration', function () {
|
||||
expect(Schema::hasColumn('song_slides', 'label_id'))->toBeTrue();
|
||||
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', 'song_section_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
|
||||
expect(Schema::hasColumn('song_arrangement_labels', 'label_id'))->toBeFalse();
|
||||
expect(Schema::hasColumns('song_arrangement_labels', ['id', 'song_arrangement_id', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('song_groups table is dropped', function () {
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
<?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();
|
||||
});
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?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");
|
||||
});
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
<?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')
|
||||
);
|
||||
});
|
||||
|
|
@ -40,19 +40,17 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
|
|||
['name' => 'Verse 1 - '.$title],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$verseSection = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$verseSection->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
|
||||
$chorus = Label::firstOrCreate(
|
||||
['name' => 'Chorus - '.$title],
|
||||
['color' => '#F44336'],
|
||||
);
|
||||
$chorusSection = $song->sections()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
$chorusSection->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,291 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,17 +7,10 @@
|
|||
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
|
||||
|
|
@ -39,21 +32,19 @@ private function createSongWithContent(): Song
|
|||
['name' => 'Verse 1 - Export Test Song'],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$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']);
|
||||
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
|
||||
|
||||
$chorus = Label::firstOrCreate(
|
||||
['name' => 'Chorus - Export Test Song'],
|
||||
['color' => '#F44336'],
|
||||
);
|
||||
$chorusSection = $song->sections()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
$chorusSection->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$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]);
|
||||
$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]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
@ -122,13 +113,13 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
$importResponse->assertOk();
|
||||
|
||||
$songId = $importResponse->json('songs.0.id');
|
||||
$originalSong = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($songId);
|
||||
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId);
|
||||
$this->assertNotNull($originalSong);
|
||||
|
||||
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
|
||||
$this->assertNotNull($defaultArr);
|
||||
|
||||
$originalArrangementSections = $defaultArr->arrangementSections->sortBy('order')->values();
|
||||
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
|
||||
$originalArrangements = $originalSong->arrangements;
|
||||
|
||||
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
|
||||
|
|
@ -146,21 +137,21 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
|
||||
$reImportedGroups = $reImported->getGroups();
|
||||
|
||||
$uniqueOriginalSections = $originalArrangementSections
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section)
|
||||
$uniqueOriginalLabels = $originalArrangementLabels
|
||||
->map(fn ($al) => $al->label)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$this->assertCount($uniqueOriginalSections->count(), $reImportedGroups, 'Group count mismatch');
|
||||
$this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch');
|
||||
|
||||
foreach ($uniqueOriginalSections as $index => $originalSection) {
|
||||
foreach ($uniqueOriginalLabels as $index => $originalLabel) {
|
||||
$reImportedGroup = $reImportedGroups[$index];
|
||||
$this->assertSame($originalSection->label->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
|
||||
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
|
||||
|
||||
$originalSlides = $originalSection->slides->sortBy('order')->values();
|
||||
$originalSlides = $originalLabel->songSlides->sortBy('order')->values();
|
||||
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
|
||||
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalSection->label->name}'");
|
||||
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'");
|
||||
|
||||
foreach ($originalSlides as $slideIndex => $originalSlide) {
|
||||
$reImportedSlide = $reImportedSlides[$slideIndex];
|
||||
|
|
@ -168,15 +159,15 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
$this->assertSame(
|
||||
$originalSlide->text_content,
|
||||
$reImportedSlide->getPlainText(),
|
||||
"Slide text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
|
||||
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
|
||||
);
|
||||
|
||||
if ($originalSlide->text_content_translated) {
|
||||
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalSection->label->name}' slide {$slideIndex}");
|
||||
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}");
|
||||
$this->assertSame(
|
||||
$originalSlide->text_content_translated,
|
||||
$reImportedSlide->getTranslation()?->getPlainText(),
|
||||
"Translation text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
|
||||
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -189,9 +180,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->arrangementSections
|
||||
$originalGroupNames = $originalArrangement->arrangementLabels
|
||||
->sortBy('order')
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
||||
->map(fn ($al) => $al->label?->name)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
@ -277,190 +268,6 @@ 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([
|
||||
|
|
@ -498,27 +305,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
|
@ -73,9 +72,8 @@ 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([
|
||||
'song_section_id' => $oldSection->id,
|
||||
'label_id' => $oldLabel->id,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
<?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();
|
||||
});
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?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();
|
||||
});
|
||||
|
|
@ -42,8 +42,13 @@
|
|||
});
|
||||
|
||||
test('migration rolls back cleanly', function (): void {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Destruktive Migration');
|
||||
|
||||
Artisan::call('migrate:rollback', ['--step' => 1]);
|
||||
|
||||
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeFalse();
|
||||
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeFalse();
|
||||
|
||||
Artisan::call('migrate');
|
||||
|
||||
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeTrue();
|
||||
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@
|
|||
$arrangement = $song->arrangements()->where('is_default', true)->first();
|
||||
expect($arrangement)->not->toBeNull();
|
||||
expect($arrangement->name)->toBe('Normal');
|
||||
expect($arrangement->arrangementSections)->toHaveCount(3);
|
||||
expect($arrangement->arrangementSections->sortBy('order')->pluck('section.label.name')->toArray())
|
||||
expect($arrangement->arrangementLabels)->toHaveCount(3);
|
||||
expect($arrangement->arrangementLabels->sortBy('order')->pluck('label.name')->toArray())
|
||||
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
|
||||
});
|
||||
|
||||
|
|
@ -125,11 +125,10 @@
|
|||
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,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -262,8 +261,6 @@
|
|||
$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,
|
||||
|
|
@ -272,12 +269,12 @@
|
|||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section1->id,
|
||||
'label_id' => $label1->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section2->id,
|
||||
'label_id' => $label2->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -286,7 +283,7 @@
|
|||
|
||||
expect($clone->name)->toBe('Klone');
|
||||
expect($clone->is_default)->toBeFalse();
|
||||
expect($clone->arrangementSections)->toHaveCount(2);
|
||||
expect($clone->arrangementSections->pluck('song_section_id')->toArray())
|
||||
->toBe($arrangement->arrangementSections->pluck('song_section_id')->toArray());
|
||||
expect($clone->arrangementLabels)->toHaveCount(2);
|
||||
expect($clone->arrangementLabels->pluck('label_id')->toArray())
|
||||
->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@
|
|||
'name' => 'Refrain',
|
||||
'color' => '#10B981',
|
||||
]);
|
||||
$section1 = songSectionFor($song, $label1, 1);
|
||||
$section2 = songSectionFor($song, $label2, 2);
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -37,13 +35,13 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section1->id,
|
||||
'label_id' => $label1->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section2->id,
|
||||
'label_id' => $label2->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,41 +44,12 @@
|
|||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [['id', 'title', 'ccli_id', 'has_translation', 'has_content', 'created_at', 'updated_at']],
|
||||
'data' => [['id', 'title', 'ccli_id', 'has_translation', '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']);
|
||||
|
|
|
|||
|
|
@ -22,17 +22,16 @@
|
|||
'name' => 'Verse 1',
|
||||
'color' => '#3B82F6',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Amazing grace how sweet the sound',
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -75,30 +74,28 @@
|
|||
'name' => 'Refrain',
|
||||
'color' => '#10B981',
|
||||
]);
|
||||
$verseSection = songSectionFor($song, $verse, 1);
|
||||
$chorusSection = songSectionFor($song, $chorus, 2);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $verseSection->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Großer Gott wir loben dich',
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Heilig heilig heilig',
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -123,10 +120,9 @@
|
|||
$label = Label::factory()->create([
|
||||
'name' => 'Verse 1',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Amazing grace how sweet the sound',
|
||||
'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang',
|
||||
|
|
@ -134,7 +130,7 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -202,17 +198,16 @@
|
|||
$label = Label::factory()->create([
|
||||
'name' => 'Strophe 1',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Großer Gott wir loben dich',
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
@ -256,17 +251,15 @@
|
|||
'name' => 'Refrain',
|
||||
'color' => '#ef4444',
|
||||
]);
|
||||
$verseSection = songSectionFor($song, $verse, 1);
|
||||
$chorusSection = songSectionFor($song, $chorus, 2);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $verseSection->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Strophe Text',
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Refrain Text',
|
||||
]);
|
||||
|
|
@ -278,13 +271,13 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -317,10 +310,9 @@
|
|||
$label = Label::factory()->create([
|
||||
'name' => 'Verse',
|
||||
]);
|
||||
$section = songSectionFor($song, $label);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Original Text',
|
||||
'text_content_translated' => 'Translated Text',
|
||||
|
|
@ -332,7 +324,7 @@
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Models\SongSlide;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SongSectionControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_patch_edits_only_target_song_section(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$label = Label::factory()->create(['name' => 'Chorus']);
|
||||
[$songA, $sectionA] = $this->createSongWithSection($label, ['Alter Refrain']);
|
||||
[, $sectionB] = $this->createSongWithSection($label, ['Anderer Refrain']);
|
||||
|
||||
$response = $this->patchJson(route('songs.sections.update', [$songA, $sectionA]), [
|
||||
'slides' => [
|
||||
['text_content' => 'Neuer Refrain', 'text_content_translated' => 'New chorus'],
|
||||
['text_content' => 'Zweiter Block', 'text_content_translated' => null],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.has_translation', true)
|
||||
->assertJsonPath('data.groups.0.slides.0.text_content', 'Neuer Refrain');
|
||||
|
||||
$this->assertSame(['Neuer Refrain', 'Zweiter Block'], $sectionA->slides()->orderBy('order')->pluck('text_content')->all());
|
||||
$this->assertSame(['Anderer Refrain'], $sectionB->slides()->orderBy('order')->pluck('text_content')->all());
|
||||
$this->assertTrue($songA->fresh()->has_translation);
|
||||
}
|
||||
|
||||
public function test_post_adds_section_reuses_normalized_label_and_appends_default_arrangement(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Normal',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$existingLabel = Label::factory()->create(['name' => 'Verse 3', 'color' => '#111111']);
|
||||
|
||||
$response = $this->postJson(route('songs.sections.store', $song), [
|
||||
'label_name' => 'Strophe 3',
|
||||
'color' => '#ABCDEF',
|
||||
'slides' => [
|
||||
['text_content' => 'Neue Strophe', 'text_content_translated' => null],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.groups.0.name', 'Verse 3')
|
||||
->assertJsonPath('data.groups.0.slides.0.text_content', 'Neue Strophe');
|
||||
|
||||
$this->assertSame(1, Label::query()->where('name', 'Verse 3')->count());
|
||||
$section = SongSection::query()->where('song_id', $song->id)->where('label_id', $existingLabel->id)->first();
|
||||
|
||||
$this->assertNotNull($section);
|
||||
$this->assertDatabaseHas('song_slides', [
|
||||
'song_section_id' => $section->id,
|
||||
'text_content' => 'Neue Strophe',
|
||||
]);
|
||||
$this->assertDatabaseHas('song_arrangement_labels', [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_removes_section_slides_and_junction_for_this_song_only_but_keeps_global_label(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$label = Label::factory()->create(['name' => 'Bridge']);
|
||||
[$songA, $sectionA, $arrangementA] = $this->createSongWithSection($label, ['Bridge A']);
|
||||
[, $sectionB] = $this->createSongWithSection($label, ['Bridge B']);
|
||||
$slideId = $sectionA->slides()->first()->id;
|
||||
$junctionId = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $arrangementA->id)
|
||||
->where('song_section_id', $sectionA->id)
|
||||
->value('id');
|
||||
|
||||
$response = $this->deleteJson(route('songs.sections.destroy', [$songA, $sectionA]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.groups', []);
|
||||
|
||||
$this->assertDatabaseMissing('song_sections', ['id' => $sectionA->id]);
|
||||
$this->assertDatabaseMissing('song_slides', ['id' => $slideId]);
|
||||
$this->assertDatabaseMissing('song_arrangement_labels', ['id' => $junctionId]);
|
||||
$this->assertDatabaseHas('labels', ['id' => $label->id]);
|
||||
$this->assertDatabaseHas('song_sections', ['id' => $sectionB->id]);
|
||||
$this->assertSame(['Bridge B'], $sectionB->slides()->pluck('text_content')->all());
|
||||
}
|
||||
|
||||
public function test_validation_and_ownership_errors_are_german(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$label = Label::factory()->create(['name' => 'Verse 1']);
|
||||
[$songA, $sectionA] = $this->createSongWithSection($label, ['Song A']);
|
||||
[$songB] = $this->createSongWithSection(Label::factory()->create(['name' => 'Chorus']), ['Song B']);
|
||||
|
||||
$this->patchJson(route('songs.sections.update', [$songB, $sectionA]), [
|
||||
'slides' => [
|
||||
['text_content' => 'Falsch'],
|
||||
],
|
||||
])->assertNotFound()
|
||||
->assertJsonPath('message', 'Sektion nicht gefunden.');
|
||||
|
||||
$this->postJson(route('songs.sections.store', $songA), [
|
||||
'slides' => [
|
||||
['text_content' => 'Ohne Label'],
|
||||
],
|
||||
])->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Bitte gib einen Namen für die Sektion ein.');
|
||||
|
||||
$this->postJson(route('songs.sections.store', $songA), [
|
||||
'label_name' => 'Verse 1',
|
||||
'slides' => [
|
||||
['text_content' => 'Doppelt'],
|
||||
],
|
||||
])->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Dieser Abschnitt existiert bereits in diesem Lied.');
|
||||
}
|
||||
|
||||
public function test_has_translation_is_recomputed_after_edits(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
[$song, $section] = $this->createSongWithSection(Label::factory()->create(['name' => 'Verse 1']), ['Original']);
|
||||
|
||||
$this->patchJson(route('songs.sections.update', [$song, $section]), [
|
||||
'slides' => [
|
||||
['text_content' => 'Original', 'text_content_translated' => 'Übersetzt'],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('data.has_translation', true);
|
||||
|
||||
$this->assertTrue($song->fresh()->has_translation);
|
||||
|
||||
$this->patchJson(route('songs.sections.update', [$song, $section]), [
|
||||
'slides' => [
|
||||
['text_content' => 'Original', 'text_content_translated' => ''],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('data.has_translation', false);
|
||||
|
||||
$this->assertFalse($song->fresh()->has_translation);
|
||||
}
|
||||
|
||||
private function createSongWithSection(Label $label, array $slides): array
|
||||
{
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
$section = SongSection::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
foreach ($slides as $index => $text) {
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $index + 1,
|
||||
'text_content' => $text,
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Normal',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
return [$song, $section, $arrangement];
|
||||
}
|
||||
}
|
||||
|
|
@ -88,8 +88,6 @@ 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,
|
||||
|
|
@ -99,13 +97,13 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
|
|||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_section_id' => $verseSection->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_section_id' => $chorusSection->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,37 +38,35 @@ 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,
|
||||
'song_section_id' => $sectionFirst->id,
|
||||
'label_id' => $labelFirst->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $sectionLater->id,
|
||||
'label_id' => $labelLater->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $sectionFirst->id,
|
||||
'label_id' => $labelFirst->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Zeile A\nZeile B",
|
||||
'text_content_translated' => "Line A\nLine B",
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $sectionFirst->id,
|
||||
'label_id' => $labelFirst->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Zeile C\nZeile D\nZeile E",
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $sectionLater->id,
|
||||
'label_id' => $labelLater->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Refrain',
|
||||
'text_content_translated' => 'Chorus',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
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;
|
||||
|
|
@ -71,24 +70,23 @@ function makeSongWithDefaultArrangement(): array
|
|||
return [$song, $arrangement];
|
||||
}
|
||||
|
||||
function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): SongSection
|
||||
function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): Label
|
||||
{
|
||||
$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,
|
||||
'song_section_id' => $section->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => $arrangementOrder,
|
||||
]);
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$section->slides()->create($slide);
|
||||
SongSlide::factory()->create(array_merge(
|
||||
['label_id' => $label->id],
|
||||
$slide,
|
||||
));
|
||||
}
|
||||
|
||||
return $section;
|
||||
return $label;
|
||||
}
|
||||
|
||||
test('importTranslation distributes lines by slide line counts', function () {
|
||||
|
|
@ -97,19 +95,19 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Original 5\nOriginal 6",
|
||||
]);
|
||||
|
||||
$slide3 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 3,
|
||||
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
|
||||
]);
|
||||
|
|
@ -134,13 +132,13 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label1->id,
|
||||
'label_id' => $label1->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line A\nLine B",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label2->id,
|
||||
'label_id' => $label2->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line C\nLine D\nLine E",
|
||||
]);
|
||||
|
|
@ -162,13 +160,13 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1\nLine 2\nLine 3",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Line 4\nLine 5",
|
||||
]);
|
||||
|
|
@ -189,7 +187,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
|
||||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1);
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Line 1',
|
||||
]);
|
||||
|
|
@ -216,14 +214,14 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Original',
|
||||
'text_content_translated' => 'Übersetzt',
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 2,
|
||||
'text_content' => 'Original 2',
|
||||
'text_content_translated' => 'Übersetzt 2',
|
||||
|
|
@ -284,7 +282,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1);
|
||||
|
||||
$slide = SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1\nLine 2",
|
||||
]);
|
||||
|
|
@ -330,7 +328,7 @@ function attachLabelWithSlides(SongArrangement $arrangement, string $labelName,
|
|||
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_section_id' => $label->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
'text_content' => 'Original',
|
||||
'text_content_translated' => 'Übersetzt',
|
||||
|
|
|
|||
|
|
@ -41,21 +41,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
function songSectionFor(\App\Models\Song $song, \App\Models\Label $label, int $order = 1): \App\Models\SongSection
|
||||
function something()
|
||||
{
|
||||
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));
|
||||
// ..
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
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
Loading…
Reference in a new issue