Compare commits
No commits in common. "master" and "dev-with-ddev" have entirely different histories.
master
...
dev-with-d
|
|
@ -1,8 +0,0 @@
|
|||
ls tests/fixtures/ccli/*.txt | wc -l
|
||||
22
|
||||
|
||||
grep -rl "Strophe\|Refrain" tests/fixtures/ccli/ | wc -l
|
||||
4
|
||||
|
||||
grep -rl "(Repeat)" tests/fixtures/ccli/ | wc -l
|
||||
2
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
ddev exec php artisan test --filter=CcliFixtureSanityTest
|
||||
|
||||
PASS Tests\Feature\CcliFixtureSanityTest
|
||||
✓ ccli fixture corpus has at least 20 txt files
|
||||
✓ each ccli fixture is valid utf8 with section labels and title
|
||||
✓ ccli fixture corpus covers german labels
|
||||
✓ ccli fixture corpus covers repeat markers
|
||||
|
||||
Tests: 4 passed (160 assertions)
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
php artisan test tests/Feature/Migrations/LabelsTableTest.php
|
||||
|
||||
PASS Tests\Feature\Migrations\LabelsTableTest
|
||||
✓ labels table has expected columns 0.40s
|
||||
✓ labels table enforces unique name 0.01s
|
||||
✓ labels table allows nullable color 0.01s
|
||||
|
||||
Tests: 3 passed (4 assertions)
|
||||
Duration: 0.54s
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
migrate:fresh output
|
||||
|
||||
Dropping all tables ........................................... 13.01ms DONE
|
||||
|
||||
INFO Preparing database.
|
||||
|
||||
Creating migration table ....................................... 4.76ms DONE
|
||||
|
||||
INFO Running migrations.
|
||||
|
||||
0001_01_01_000000_create_users_table ........................... 9.49ms DONE
|
||||
0001_01_01_000001_create_cache_table ........................... 4.35ms DONE
|
||||
0001_01_01_000002_create_jobs_table ............................ 4.88ms DONE
|
||||
2026_03_01_100000_extend_users_table ........................... 4.60ms DONE
|
||||
2026_03_01_100100_create_services_table ........................ 2.92ms DONE
|
||||
2026_03_01_100200_create_songs_table ........................... 2.08ms DONE
|
||||
2026_03_01_100300_create_song_groups_table ..................... 3.09ms DONE
|
||||
2026_03_01_100400_create_song_slides_table ..................... 5.10ms DONE
|
||||
2026_03_01_100500_create_song_arrangements_table ............... 3.19ms DONE
|
||||
2026_03_01_100600_create_song_arrangement_groups_table ......... 3.61ms DONE
|
||||
2026_03_01_100700_create_service_songs_table ................... 3.25ms DONE
|
||||
2026_03_01_100800_create_slides_table .......................... 3.68ms DONE
|
||||
2026_03_01_100900_create_cts_sync_log_table .................... 4.32ms DONE
|
||||
2026_03_02_100000_create_api_request_logs_table ................ 2.15ms DONE
|
||||
2026_03_02_121522_add_response_body_to_api_request_logs_table .. 1.31ms DONE
|
||||
2026_03_02_130249_add_cts_song_id_to_songs_and_service_songs_tables 3.30ms DONE
|
||||
2026_03_02_140000_add_sort_order_to_slides_table ............... 0.91ms DONE
|
||||
2026_03_02_200000_create_settings_table ........................ 2.48ms DONE
|
||||
2026_03_29_100001_create_service_agenda_items_table ............ 3.03ms DONE
|
||||
2026_03_29_100002_add_service_agenda_item_id_to_slides_table .. 13.03ms DONE
|
||||
2026_03_29_131045_add_missing_cts_song_id_to_service_songs_table 0.53ms DONE
|
||||
2026_03_29_131359_add_has_agenda_to_services_table ............. 1.24ms DONE
|
||||
2026_05_03_100100_create_labels_table .......................... 2.50ms DONE
|
||||
|
|
@ -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,7 +0,0 @@
|
|||
Command: ddev exec php artisan test --filter=CcliLabelsTest
|
||||
Result: PASS
|
||||
Assertions: 71
|
||||
Tests: 55 passed
|
||||
|
||||
Command: ddev exec ./vendor/bin/pint --test app/Support/CcliLabels.php tests/Unit/CcliLabelsTest.php
|
||||
Result: PASS
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
Command: ddev exec php artisan tinker --execute="var_export([App\\Support\\CcliLabels::normalizeLabelName('Foobar'), App\\Support\\CcliLabels::normalizeLabelName('')]);"
|
||||
Result:
|
||||
array (
|
||||
0 => 'Foobar',
|
||||
1 => '',
|
||||
)
|
||||
|
|
@ -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 +0,0 @@
|
|||
CAUGHT: Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
array:3 [
|
||||
"title" => "Test Song 3"
|
||||
"sections" => 2
|
||||
"has_translation" => true
|
||||
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:4
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
array:2 [
|
||||
"repeat_sections" => 1
|
||||
"modifier" => "Repeat"
|
||||
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:3
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
array:2 [
|
||||
"title" => "Test Song 15"
|
||||
"has_umlauts" => true
|
||||
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:3
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
Command: ddev exec php artisan test --filter='blocks active duplicate ccli_id with DuplicateCcliSongException'
|
||||
|
||||
PASS: Active duplicate CCLI-ID is blocked.
|
||||
|
||||
Verified assertions:
|
||||
- first import succeeds
|
||||
- second import throws App\Exceptions\DuplicateCcliSongException
|
||||
- exception existingSongId matches original Song ID
|
||||
- exception message contains "existiert bereits"
|
||||
- Song::count() remains 1
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
Command: ddev exec php artisan test --filter='imports english-only fixture and creates song with default arrangement'
|
||||
|
||||
PASS: CcliImportService imported tests/fixtures/ccli/english-only-multi-verse.txt
|
||||
|
||||
Verified assertions:
|
||||
- status = created
|
||||
- Song title = Test Song 1
|
||||
- Song ccli_id = 9999001
|
||||
- imported_from_ccli_at is set
|
||||
- ccli_source_url = https://songselect.ccli.com/Songs/9999001
|
||||
- default arrangement name = normal, is_default = true
|
||||
- arrangement label entries = 5
|
||||
- slide rows = 9
|
||||
|
|
@ -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,9 +0,0 @@
|
|||
Command: ddev exec php artisan test --filter='restores soft-deleted song and does not duplicate normal arrangement'
|
||||
|
||||
PASS: Soft-deleted CCLI match is restored instead of duplicated.
|
||||
|
||||
Verified assertions:
|
||||
- restored import status = restored
|
||||
- returned Song ID matches original soft-deleted Song ID
|
||||
- restored song is no longer trashed
|
||||
- only one normal arrangement exists after restore/import
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
Command: ddev exec php artisan test --filter='rolls back song and log when slide creation fails'
|
||||
|
||||
PASS: Transaction rollback verified via SQLite trigger failure on song_slides insert.
|
||||
|
||||
Verified assertions:
|
||||
- import throws Illuminate\Database\QueryException
|
||||
- Song::count() = 0 after failure
|
||||
- SongSlide::count() = 0 after failure
|
||||
- ApiRequestLog::count() = 0 after failure
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
Task 8 evidence: German local labels pair with English CCLI labels.
|
||||
|
||||
Command run:
|
||||
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
|
||||
|
||||
Relevant passing test:
|
||||
✓ pairs German local labels with English CCLI labels via normalization
|
||||
|
||||
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
|
||||
- Local "Strophe 1" normalizes to canonical "verse 1" and matches CCLI "Verse 1".
|
||||
- Local "Refrain" normalizes to canonical "chorus" and matches CCLI "Chorus".
|
||||
- unmatched_labels is empty.
|
||||
- mapping has three entries.
|
||||
|
||||
Full targeted result:
|
||||
Tests: 5 passed (20 assertions)
|
||||
Duration: 0.46s
|
||||
|
|
@ -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,18 +0,0 @@
|
|||
Task 8 evidence: unmatched local sections are returned for UI review.
|
||||
|
||||
Command run:
|
||||
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
|
||||
|
||||
Relevant passing test:
|
||||
✓ returns unmatched_labels for sections not in CCLI
|
||||
|
||||
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
|
||||
- Local arrangement contains Verse 1, Chorus, Bridge.
|
||||
- CCLI paste contains Verse 1 and Chorus only.
|
||||
- result['unmatched_labels'] contains "Bridge".
|
||||
- mapping still has all three local labels.
|
||||
- Bridge mapping has ccli_label = null and empty distributed line placeholders.
|
||||
|
||||
Full targeted result:
|
||||
Tests: 5 passed (20 assertions)
|
||||
Duration: 0.46s
|
||||
|
|
@ -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,35 +0,0 @@
|
|||
# CCLI SongSelect Import — Issues
|
||||
|
||||
## [2026-05-10] Known Issues / Gotchas
|
||||
|
||||
### T1: Fixture corpus requires structurally accurate CCLI format
|
||||
- Plan originally asked for "real CCLI text pastes" but since we can't access SongSelect, agent creates synthetic fixtures.
|
||||
- Synthetic fixtures MUST match exact CCLI format: title line, blank, section label, lyrics, blank, footer with © and CCLI #.
|
||||
- The parser tests depend on these fixtures — structural accuracy is critical.
|
||||
|
||||
### T7: ProImportService method name
|
||||
- Plan was corrected: the public method is `ProImportService::import(UploadedFile $file)`, not `upsertSong()`.
|
||||
- When mirroring the pattern, READ app/Services/ProImportService.php before implementing.
|
||||
|
||||
### No Admin Role
|
||||
- No `admin` role or Policy exists in the codebase.
|
||||
- CCLI Settings section is visible to ALL authenticated users.
|
||||
- Document this decision, don't create a policy gate.
|
||||
|
||||
### AGENDA_KEYS whitelist
|
||||
- SettingsController has a `const AGENDA_KEYS` array.
|
||||
- T4 MUST add `'default_translation_language'` to this array OR update the validation to include it.
|
||||
- Failure to update AGENDA_KEYS = PATCH /settings will silently ignore the new key.
|
||||
|
||||
### Translation Pairing Label Direction
|
||||
- CCLI paste can have English labels; local songs may have German labels (Strophe, Refrain).
|
||||
- CcliLabels::normalizeLabelName() normalizes BOTH directions to canonical English before pairing.
|
||||
- Do NOT assume same language on both sides.
|
||||
|
||||
### T7: Global Label Slide Replacement Caveat
|
||||
- The requested CCLI import pattern deletes `songSlides()` on the resolved global `Label` before recreating slides.
|
||||
- Because labels are shared globally, a later import using the same canonical label name replaces that label's slide text globally; this intentionally matches the task spec and existing `ProImportService` pattern, but remains a design caveat for future per-song slide ownership work.
|
||||
|
||||
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
|
||||
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
|
||||
- Missing labels are returned in `unmatched_labels`, and their mapping entries keep empty slide placeholders so `distributed_text` still aligns with the local arrangement shape.
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# CCLI SongSelect Import — Learnings
|
||||
|
||||
## [2026-05-10] Session Start
|
||||
|
||||
### Architecture Decisions
|
||||
- Manual paste + bookmarklet approach (NO server-side scraping — Cloudflare/ToS blocker)
|
||||
- CcliPasteParser is closure-injectable (mirrors ChurchToolsService pattern for testability)
|
||||
- All songs upserted via CcliImportService mirroring ProImportService::import() shape
|
||||
- Translation stored inline on SongSlide.text_content_translated (no separate model)
|
||||
- default_translation_language = APP-GLOBAL Setting (not per-user)
|
||||
|
||||
### Key Codebase Facts
|
||||
- Song.ccli_id is UNIQUE indexed nullable — primary CCLI match key
|
||||
- SongSlide: text_content (original), text_content_translated (translation)
|
||||
- Labels are GLOBAL (shared across all songs) — labels table with name + color
|
||||
- ProImportService::import() is the template for upsert (not upsertSong — that method doesn't exist)
|
||||
- SettingsController::AGENDA_KEYS constant whitelist for Settings KV
|
||||
- TranslationService::importFromText distributes lines preserving local slide line counts
|
||||
- ArrangementDialog.vue lines 488-532 = searchable song select (where CCLI buttons go)
|
||||
|
||||
### CCLI SongSelect "View Lyrics" Page Format
|
||||
- Title on first non-empty line
|
||||
- Section label as standalone line (e.g., "Verse 1", "Chorus")
|
||||
- Lyrics lines under each section
|
||||
- Footer: copyright (©), CCLI number (e.g., "CCLI # 1234567"), author
|
||||
|
||||
### Section Label Regex (English + German + variants)
|
||||
```
|
||||
/^(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+)?$/i
|
||||
```
|
||||
|
||||
### Language Mapping (EN ↔ DE)
|
||||
- Verse ↔ Strophe
|
||||
- Chorus ↔ Refrain
|
||||
- Bridge ↔ Brücke
|
||||
- Pre-Chorus ↔ Vorrefrain
|
||||
- Ending ↔ Schluss
|
||||
- Interlude ↔ Zwischenspiel
|
||||
|
||||
### Test Fixture Format
|
||||
Fixtures are synthetic CCLI-format text files. Format:
|
||||
```
|
||||
Test Song Title
|
||||
Test Artist
|
||||
|
||||
Verse 1
|
||||
Line 1 of verse
|
||||
Line 2 of verse
|
||||
|
||||
Chorus
|
||||
Chorus line 1
|
||||
Chorus line 2
|
||||
|
||||
© 2024 Test Publishing
|
||||
CCLI # 9999001
|
||||
```
|
||||
|
||||
### Fixture Corpus Notes
|
||||
- Keep fixture titles/artists anonymized and numeric (`Test Song N`, `Test Artist N`)
|
||||
- Include both English and German section labels in the corpus so parser regex coverage stays broad
|
||||
- Add edge cases for missing footer pieces, whitespace, repeat markers, and suffix labels (`2a`, `x2`, `(Repeat)`)
|
||||
|
||||
### 2026-05-10 CCLI Label Utility Notes
|
||||
- `CcliLabels` works best with a fixed kind list in regexes; no locale config needed for EN/DE normalization.
|
||||
- `normalizeLabelName()` should map only known German kinds and preserve any numeric suffix.
|
||||
- `parseLabel()` can stay lightweight by returning `null` for non-labels and a small array for matched labels.
|
||||
|
||||
### 2026-05-10 Song CCLI Metadata Migration
|
||||
- Song CCLI metadata belongs on `songs` as nullable fields: `imported_from_ccli_at` (timestamp) + `ccli_source_url` (string 500).
|
||||
- Factory state helpers can stay tiny; `fromCcli()` just seeds timestamp + SongSelect URL.
|
||||
- Inference/LSP can lag after edits; a tiny no-op signature change (`fn (): array => [...]`) forced the factory diagnostics to refresh cleanly.
|
||||
|
||||
### 2026-05-10 Settings Language Seed
|
||||
- `SettingsController::AGENDA_KEYS` drives both the index props and the allowed `key` values for PATCH updates.
|
||||
- `default_translation_language` should be validated as a whitelist value (`DE|EN|FR|ES|NL|IT`) only when that setting is being updated.
|
||||
- `CcliSettingsSeeder` must use `Setting::firstOrCreate()` so reseeding does not overwrite a user-changed language.
|
||||
|
||||
### 2026-05-10 CcliPasteParser Scaffold
|
||||
- Mirror `ChurchToolsService` with nullable `Closure` constructor injections and default `= null` values.
|
||||
- This codebase uses `App\Services\DTO\...` namespaces/directories for DTOs, so keep the uppercase `DTO` path aligned with existing services.
|
||||
- Scaffold tests can verify Laravel container resolution without adding any service provider binding.
|
||||
|
||||
### 2026-05-10 CcliPasteParser Implementation
|
||||
- Parser trims pasted lines, treats blank lines as separators, extracts first two header lines as title/author, and excludes CCLI metadata from lyric sections.
|
||||
- EN/DE side-by-side imports merge only adjacent labels with different raw kinds but the same `CcliLabels::normalizeLabelName()` canonical kind/number, preserving German lyrics in `linesTranslated`.
|
||||
- DDEV/Linux path is `tests/fixtures/ccli` (lowercase); macOS accepted `tests/Fixtures/ccli`, but tests must use lowercase for container portability.
|
||||
|
||||
### 2026-05-10 CcliImportService Implementation
|
||||
- `CcliImportService` mirrors `ProImportService` by wrapping song metadata, global label resolution, slide replacement, default arrangement upsert, arrangement-label recreation, and `ApiRequestLog` success entry in one `DB::transaction()`.
|
||||
- Active duplicate CCLI IDs are blocked with `DuplicateCcliSongException`; trashed matches are restored and updated in-place via `Song::withTrashed()->where('ccli_id', ...)`.
|
||||
- CCLI label names should be canonicalized with `CcliLabels::normalizeLabelName($kind.' '.$number)` before `Label::firstOrCreate()`, keeping labels global/shared.
|
||||
- Import tests can verify rollback deterministically with a temporary SQLite trigger that aborts `song_slides` insert; this proves song + log rows are not persisted after mid-transaction failure.
|
||||
|
||||
### 2026-05-10 CCLI Parser Review Fixes
|
||||
- CCLI SongSelect metadata can appear as `CCLI Song #`, `CCLI-Nr.` or `CCLI-Liednummer`; extraction must ignore `CCLI License/Lizenz` numbers.
|
||||
- Parsed section `kind` is canonicalized via `CcliLabels::normalizeLabelName()`, while the original pasted label remains available in `label`; translation pairing still compares raw label kinds internally.
|
||||
|
||||
### 2026-05-10 CCLI Translation Pairing
|
||||
- `CcliTranslationPairingService` returns a review-only mapping and never writes `SongSlide.text_content_translated`; callers remain responsible for persistence.
|
||||
- Pairing canonicalizes both local arrangement labels and CCLI sections with `CcliLabels::normalizeLabelName()` + lowercase, so `Strophe 1` ↔ `Verse 1` and `Refrain` ↔ `Chorus` work across languages.
|
||||
- Distribution mirrors `TranslationService::importTranslation()` by filling local slide slots in arrangement order using each local slide's original line count; overflow CCLI lines are kept on the final local slide for that section.
|
||||
|
||||
### 2026-05-11 CcliPasteController (T10)
|
||||
- `SongMatchingService::manualAssign(ServiceSong, Song)` takes a **Song object** (not int id) — different from initial task plan.
|
||||
- API routes use `auth:sanctum` middleware (not just `auth`); Sanctum's `EnsureFrontendRequestsAreStateful` is prepended globally to API in bootstrap/app.php.
|
||||
- Apply `throttle:30,1` via a nested `Route::middleware('throttle:30,1')->group(...)` inside the existing sanctum group; combined middleware shown by `route:list -vv`.
|
||||
- `assertInertia()` enforces page-component file existence by default. For pages whose Vue component is created in a later task (T16 `Songs/ImportFromCcliPaste`), pass `$shouldExist=false` to `component()` as second arg.
|
||||
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
|
||||
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
|
||||
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
|
||||
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
|
||||
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
|
||||
- 2026-05-03: `MacroColorConverter::fromRgba()` muss nur RGB clampen und als uppercase-6-digit Hex ausgeben; `tinker --execute` ist eine schnelle Verifikation fuer solche statischen Helper.
|
||||
|
||||
## [2026-03-01] Wave 2 Complete — T8-T13
|
||||
|
||||
|
|
@ -351,5 +350,3 @@ ### Verification Success Criteria Met
|
|||
### Next Steps
|
||||
- Task 2 will likely involve testing OAuth login flow with ChurchTools
|
||||
- May need to configure CTS_API_TOKEN and CHURCHTOOLS credentials for full testing
|
||||
|
||||
- 2026-05-04: `ProBundleExportService` muss fuer non-song `.probundle` Exporte den aktuellen `Service` plus `part_type` bis in `buildBundleFromSlides()` durchreichen; `MacroResolutionService::macrosForSlide()` bekommt fuer Bildfolien `label_id => null`, damit nur all/first/last Positionen greifen.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# Decisions — macros-and-labels-import
|
||||
|
||||
## [2026-05-03] Architectural Decisions
|
||||
|
||||
### Schema
|
||||
- **labels table**: global, unique by name, nullable color, hidden_at (NOT deleted_at)
|
||||
- **macros table**: unique by uuid (uppercase), hidden_at (NOT deleted_at)
|
||||
- **macro_assignments**: restrictOnDelete on macro_id and label_id FKs
|
||||
- **service_macro_overrides**: existence of row = override active; no extra boolean
|
||||
- **song_arrangement_labels**: replaces song_arrangement_groups; references global label_id
|
||||
|
||||
### Macro Assignment Semantics
|
||||
- `part_type` enum: `information | moderation | sermon | song | agenda_item`
|
||||
- `position` enum: `all_slides | first_slide | last_slide | by_label`
|
||||
- `by_label` is valid for ALL part_types (not songs-only) — validated at app level if restriction needed
|
||||
- Stacking: multiple assignments can fire on same slide — all applied in `order ASC`
|
||||
- Override wins 100% — no globals bleed through when override exists
|
||||
|
||||
### Override Semantics
|
||||
- "Anpassen" snapshots current globals into `service_macro_assignments` rows
|
||||
- "Auf Standard zurücksetzen" deletes the override row + cascades service_macro_assignments
|
||||
- German tooltip: "Erstellt eine Kopie der aktuellen globalen Zuweisungen für diesen Gottesdienst. Spätere Änderungen an den globalen Zuweisungen wirken sich auf diesen Gottesdienst NICHT mehr aus."
|
||||
|
||||
### Data Migration
|
||||
- Destructive: `up()` deletes songs, song_groups, song_slides, song_arrangements, song_arrangement_groups
|
||||
- `down()` throws RuntimeException (irreversible)
|
||||
- Guard: `if (!Schema::hasTable('song_groups') || !DB::table('song_groups')->exists()) return;`
|
||||
- Old 4 macro settings keys → migrated to global assignment if all present; then deleted
|
||||
|
||||
### Label Color Priority
|
||||
1. Labels file import → always sets/overwrites color
|
||||
2. .pro song import → only sets color on CREATE (new label); existing color preserved
|
||||
3. UI → read-only (no manual edit)
|
||||
|
||||
### Current Migration Scope
|
||||
- `labels` migration only defines the schema; no model or business logic belongs in this task
|
||||
- Use `hidden_at` instead of `deleted_at` to align with soft-hide semantics
|
||||
|
||||
### Macros Tables Task
|
||||
- Keep all three tables in one migration file so the schema lands together and the junction FKs resolve cleanly during `migrate:fresh`
|
||||
- Store `last_imported_filename` as nullable text metadata on `macros`; no separate import log table for this task
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
# Learnings — macros-and-labels-import
|
||||
|
||||
## [2026-05-03] Session ses_210cd1557ffeGs4SEGrt7hnvyS — Plan Created
|
||||
|
||||
### Parser Library
|
||||
- Source at `/Users/thorsten/AI/propresenter/src/` (NOT `/Users/thorsten/AI/propresenter-work/php/` per stale AGENTS.md)
|
||||
- VCS repo: `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (dev-master)
|
||||
- New classes (NOT yet in vendor/): `MacrosFileReader`, `LabelsFileReader`, `Macro`, `MacroLibrary`, `MacroCollection`, `Label`, `LabelLibrary`
|
||||
- `MacrosFileReader::read(string $filePath): MacroLibrary` — raw protobuf binary, no extension
|
||||
- `LabelsFileReader::read(string $filePath): LabelLibrary` — same
|
||||
- `Label::getName()` returns protobuf `text` field — name is the identity (no UUID for labels)
|
||||
- `Macro::getColor()` returns `?array{r,g,b,a}` floats 0..1 — need `MacroColorConverter` to get hex
|
||||
- `Label::getColorHex()` already returns `#RRGGBB` — mirror its formula for macros
|
||||
- **PHP 8.4 required** by parser. App currently requires `^8.2` — BLOCKER for T0.1
|
||||
|
||||
### DB Schema Key Facts
|
||||
- `slides.type` enum is `[information, moderation, sermon]` ONLY — no `agenda_item`
|
||||
- `agenda_item` part_type = slide where `service_agenda_item_id IS NOT NULL` at runtime
|
||||
- `song_groups.color` is NOT NULLABLE (migration says so) — new `labels.color` IS nullable
|
||||
- `service_songs.song_id` is `cascadeOnDelete` — wiping `songs` auto-cascades to `service_songs`
|
||||
|
||||
### Export Flow
|
||||
- `ProExportService::buildGroups()` lines 38-69 — macro injection point
|
||||
- `ProExportService::buildMacroData()` lines 71-86 — reads 4 legacy settings keys
|
||||
- Currently injects macro ONLY when group name is "COPYRIGHT" (case-insensitive)
|
||||
- `ProImportService::import(UploadedFile $file): array` — method signature (NOT `importFromFile`)
|
||||
|
||||
### Settings Pattern
|
||||
- `Setting::get($key, $default)` / `Setting::set($key, $value)` — simple key/value
|
||||
- `settings` table: `key UNIQUE, value TEXT`
|
||||
|
||||
### Critical Decisions
|
||||
- song_groups → labels: global table, "drop all data" migration (no backwards compat)
|
||||
- Hybrid macro scope: global defaults in Settings; per-(service, part_type) override via "Anpassen"
|
||||
- Override = snapshot of globals at creation time; future global changes don't propagate
|
||||
- Stacking: all matching assignments fire, ordered by `macro_assignments.order ASC`
|
||||
- Hidden macros/labels: skip at export, warning badge in editor
|
||||
- Label colors: read-only in UI; Labels file import is sole authority; .pro auto-discovery only sets color on CREATE
|
||||
- FK rules: `restrictOnDelete` on macro/label refs (use `hidden_at`); `cascadeOnDelete` on service-scoped rows
|
||||
|
||||
### Migration/Test Notes
|
||||
- `tests/Pest.php` already applies `RefreshDatabase` to all `Feature` tests; no extra setup needed for `Feature/Migrations`
|
||||
- SQLite unique constraint errors can be asserted with `->toThrow(\Exception::class)` in migration tests
|
||||
- `macro_collection_macros` can safely use a reserved-ish `order` column name in SQLite/Laravel migrations; the schema + foreign keys passed `migrate:fresh`
|
||||
- `foreignId()->constrained()->cascadeOnDelete()` correctly cascades through the junction table under the current sqlite test setup
|
||||
|
||||
## T2.1 — Models + Factories (label-based schema)
|
||||
|
||||
- **All new model conventions match house style**: `$fillable` array, `casts()` method (not `$casts` property), typed return types on relations.
|
||||
- **`hidden_at` semantics, NOT SoftDeletes**: `Label` and `Macro` use `hidden_at` timestamp + `isHidden()` helper; SoftDeletes deliberately not used.
|
||||
- **`MacroCollection` pivot ordering**: `belongsToMany(Macro::class, 'macro_collection_macros')->withPivot('order')->orderBy('macro_collection_macros.order')` — must qualify the column with the pivot table name to avoid SQLite ambiguous column errors.
|
||||
- **`ServiceMacroOverride::assignments()` uses composite-key relation**: HasMany on `service_id` with explicit `where('part_type', $this->part_type)` filter (Eloquent has no native composite-FK support).
|
||||
- **`SongArrangement::arrangementLabels()` ordered**: `hasMany(SongArrangementLabel::class)->orderBy('order')` so consumers see labels in the correct slide order without re-sorting.
|
||||
- **`SongArrangementLabelFactory`** uses `Label::factory()` and `SongArrangement::factory()` directly — both have HasFactory trait.
|
||||
- **Test gating**: After T2.1 alone, 270/328 tests pass. The remaining 58 failures are all in `app/Services/SongService.php`, `app/Services/ProImportService.php`, and the test files that exercise those services; T4.4 owns those updates.
|
||||
- **`DatabaseSchemaTest`** passes cleanly (3 tests / 31 assertions): all expected tables exist, dropped tables gone, all factories produce valid rows.
|
||||
|
||||
## T4.4 — PHP rename audit (2026-05-03)
|
||||
|
||||
After Wave 2's schema migration (`song_groups` → `labels`, `song_arrangement_groups` → `song_arrangement_labels`), the rename-audit cleanup turned out to span **far more files** than the plan listed (12 app files + 11 test files vs 7 listed). Key findings:
|
||||
|
||||
- `Song::groups()` relation was completely removed; many call sites needed adaptation, not just rename. New pattern: traverse `Song -> arrangements -> arrangementLabels -> label -> songSlides` for content.
|
||||
- `song_slides` table only has `label_id` (no `song_id` either) — slides are now globally owned by labels. Tests that previously did `$verse = $song->groups()->create(...)` need to find/create a global Label and link it via `SongArrangementLabel`.
|
||||
- Helper functions defined at file level in Pest tests work cleanly: `function makeSongWithDefaultArrangement(): array { ... }` keeps test setup DRY.
|
||||
- Fixture `Test.pro` has 4 groups but only 3 are referenced in any arrangement — assertion needs to count `Label::count()` (post-import) to verify "all 4 groups created", not arrangement labels.
|
||||
- `MacroColorConverter::fromRgba()` (assoc-keyed `r,g,b`) replaces the old `ProImportService::rgbaToHex()` for label color conversion in importer; the legacy hex helpers were preserved because `ProFileGenerator::colorFromArray` uses numeric-indexed RGBA.
|
||||
- Removing the "groups must belong to this song" check in `ArrangementController::update` is correct since labels are global; `exists:labels,id` validation is sufficient.
|
||||
|
||||
## Wave 2 — T2.3, T2.4, T2.5 (services)
|
||||
|
||||
### LabelsImportService
|
||||
- Case-insensitive name lookup via `whereRaw('LOWER(name) = ?', [strtolower($name)])`
|
||||
- Always updates color on existing labels (additive policy, never disables)
|
||||
- Skips labels with empty names
|
||||
- Stores metadata in `settings` table: `labels_last_imported_at`, `labels_last_imported_filename`
|
||||
|
||||
### MacrosImportService
|
||||
- UUID is normalized to UPPER before storage (matches parser convention)
|
||||
- Macros not in file get `hidden_at = now()` (soft-disable, not delete)
|
||||
- Re-import re-enables a previously hidden macro by setting `hidden_at = null`
|
||||
- Tracks `wasHidden` to differentiate `reEnabled` vs `updated` counts
|
||||
- Collection sync: detach all → attach with order index from parser
|
||||
- Warnings: any MacroAssignment whose macro is currently hidden
|
||||
|
||||
### MacroResolutionService
|
||||
- Override-vs-defaults: `ServiceMacroOverride` existence check decides whether to use service-specific or global assignments
|
||||
- Hidden macros and hidden labels (for `by_label`) are filtered via Collection->reject()
|
||||
- `macrosForSlide` uses match() expression for position semantics
|
||||
- Default collection fallback: `--MAIN--` with UUID `8D02FC57-83F8-4042-9B90-81C229728426`
|
||||
|
||||
### Pint quirk
|
||||
- DTO classes with empty body need `{}` on same line as constructor closing paren — `single_line_empty_body` rule.
|
||||
|
||||
### Test patterns
|
||||
- Pest auto-applies `RefreshDatabase` via `tests/Pest.php` for all Feature tests, but explicit `uses(RefreshDatabase::class)` is harmless and matches spec.
|
||||
- All 354 tests pass (was 334 before Wave 2.3-2.5).
|
||||
|
||||
## T2.7 ProExportService MacroResolutionService
|
||||
- ProPresenter parser package currently consumes only `$slideData['macro']` in `ProFileGenerator::buildCue()`; no `$slideData['macros']` stacking support exists. `Slide::setMacro()` also updates/replaces the first macro action.
|
||||
- `ProExportService` now keeps song downloads backward-compatible by accepting optional `?Service`; exports without service context intentionally emit no macros.
|
||||
- Playlist/bundle service exports must pass the active `Service` into `generateProFile()` / `generateParserSong()` so `MacroResolutionService::macrosForSlide()` can resolve global or service-specific assignments.
|
||||
- Full verification for T2.7: `ddev exec php artisan test` passed with 357 tests / 1706 assertions; evidence in `.sisyphus/evidence/task-2.7-pest.txt`.
|
||||
|
||||
## T2.8 Controllers + Routes (2026-05-03)
|
||||
|
||||
- **4 thin controllers, all JSON responses for mutations** (Inertia only on `MacroAssignmentController::index`).
|
||||
- **Validation via inline `$request->validate()`** with `in:` lists for `part_type` (information, moderation, sermon, song, agenda_item) and `position` (all_slides, first_slide, last_slide, by_label).
|
||||
- **Route ordering matters**: `/settings/macro-assignments/reorder` MUST be registered BEFORE `/settings/macro-assignments/{macroAssignment}`, else `reorder` is captured as the model parameter.
|
||||
- **Route-model binding works automatically** for both `{macroAssignment}` and `{serviceMacroAssignment}` — Laravel resolves snake_case → StudlyCase → Eloquent model.
|
||||
- **Unused `$service` parameter on update/destroyAssignment** is intentional: route-model binding requires it in the signature even if the assignment binding alone does the work.
|
||||
- **Generic 422 message** for parser failures hides internal exception details from users; all messages German Du-form.
|
||||
- **Test fixtures `tests/fixtures/macros-sample.bin` & `labels-sample.bin`** work with `new UploadedFile(path, name, null, null, true)` (5th arg `$test=true` keeps the file at original path so `getPathname()` returns the fixture).
|
||||
- **`UploadedFile::fake()->create('x.bin', 1)`** generates a 1KB empty file that fails parser parsing → triggers the controller's catch block → 422 JSON.
|
||||
- **Auth tests use plain `post()` (form-data) → `assertRedirect(route('login'))`**; JSON requests would return 401, but session-based auth redirects.
|
||||
- **Final test count: 376 (was 357) → +19 new tests / +54 assertions.**
|
||||
|
||||
## T4.2: Service Edit Macro Panel
|
||||
|
||||
- `ServiceController::edit()` now passes `macros_per_part` keyed by part_type (information, moderation, sermon, song, agenda_item).
|
||||
- Each entry: `count`, `is_overridden`, `has_warning`, `assignments[]` (with macro_id/name/color/hidden, position, label_id/name).
|
||||
- Uses `MacroResolutionService::resolveAssignmentsForPart()` (already filters hidden macros + by_label with hidden labels). `has_warning` checks raw flags before resolver filters them — but since resolver already filters, `has_warning` will normally be false. Acceptable for badge UI.
|
||||
- `ServiceMacroOverride::where(...)->exists()` checks override status per part.
|
||||
- `ServicePartMacroPanel.vue` is positioned `absolute right-0 top-8 z-50` — wrapper must be `class="relative"`.
|
||||
- Edit.vue page only has 2 visible block headers (Ablauf and Information). Placed agenda_item/moderation/sermon/song MacroIcons in the Ablauf header row; placed information MacroIcon in the Information block header.
|
||||
- MacroIcon renders only when `count > 0`, so empty parts gracefully hide their badge.
|
||||
- Routes used: `services.macro-overrides.store` (POST + body `{part_type}`), `services.macro-overrides.destroy` (DELETE + body). XSRF token sourced from `XSRF-TOKEN` cookie (URL-decoded).
|
||||
|
||||
## Final Verification F4 (2026-05-04)
|
||||
|
||||
- Scope-fidelity verification passed: `Label`/`Macro` use `hidden_at` (no SoftDeletes), label imports are additive with color overwrite, missing macros are hidden via `hidden_at`, `MacroResolutionService` resolves override/default assignments and filters hidden macros/labels, `ProExportService` injects `MacroResolutionService` with no legacy `buildMacroData()`, and `SettingsController` only exposes the four `AGENDA_KEYS`.
|
||||
- Forbidden-pattern grep suite returned no output for label CRUD, macro action runner/editor patterns, TS suppressions, Vue console logs, bulk operations, label/macro drag reorder, and export caching.
|
||||
|
||||
## [2026-05-04] Session follow-up — hidden label badge + nullable import color
|
||||
|
||||
- `MacroAssignments.vue` should mirror hidden-macro warnings for `by_label` rows with `a.label?.hidden_at`, using a red badge and `data-testid="warning-hidden-label"`.
|
||||
- `ProImportService` must keep new label colors nullable: `MacroColorConverter::fromRgba($color)` should flow through unchanged so missing `.pro` colors become `NULL`, not `#808080`.
|
||||
|
||||
## 2026-05-04 F1 final compliance audit
|
||||
- Final verification commands passed: no Vue song_group_id references, MacroIcon and hidden-label test IDs present, ProImportService #808080 fallback removed, required macro/label deliverables and schema present, Label/Macro use hidden_at without SoftDeletes, routes present, ProBundleExportService resolves macros for information/moderation/sermon and agenda_item exports.
|
||||
- MacroResolutionService supports part types dynamically via part_type string; grep for literal part names can be empty without indicating non-support.
|
||||
|
||||
## 2026-05-04 F4 scope fidelity check
|
||||
- Must-NOT grep suite found one historical toast pattern in pre-existing service/song Vue files; no macro/label feature-specific forbidden patterns were found (no SoftDeletes/deleted_at, drag UI, runner/preview, bulk ops, optimistic markers, collection assignment, export caching, agenda_item slide enum, TS suppressions, or console.log).
|
||||
- Required macro/label evidence present: all 5 part types, position enum including by_label, hidden_at semantics, explicit restrict/cascade FKs, stacking resolver (`filter` → `map` → `values` → `all`), bundle export macro injection, German UI labels, and test IDs on macro/label picker/icon components.
|
||||
- Unaccounted grep output for `ArrangementConfigurator.vue`, `ArrangementDialog.vue`, and `SongEditModal.vue` is explained by planned SongGroup → Label rename work, not unrelated scope creep.
|
||||
149
AGENTS.md
149
AGENTS.md
|
|
@ -98,145 +98,6 @@ ## SongDB Import
|
|||
|
||||
---
|
||||
|
||||
## CCLI Import
|
||||
|
||||
Songs can be imported from CCLI SongSelect via a paste-based flow (no server-side scraping — ToS-compliant).
|
||||
|
||||
### How it works
|
||||
|
||||
1. User opens a song on **songselect.ccli.com** in their browser (must be logged in with their CCL account)
|
||||
2. User copies the full page text (Ctrl+A → Ctrl+C)
|
||||
3. User clicks "Aus CCLI importieren" in the SongDB or service form, pastes the text, clicks "Vorschau", then "Importieren"
|
||||
|
||||
Alternatively, install the **browser bookmarklet** from Settings → CCLI Import. The bookmarklet automates step 2-3 in two clicks.
|
||||
|
||||
### Key files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/Services/CcliPasteParser.php` | Pure-PHP parser: extracts title/author/CCLI-Nr/year/sections from pasted text |
|
||||
| `app/Services/CcliImportService.php` | Upserts Song + Arrangement + Labels + Slides; handles duplicates and soft-delete restore |
|
||||
| `app/Services/CcliTranslationPairingService.php` | Auto-pairs CCLI sections with local song labels for translation import |
|
||||
| `app/Support/CcliLabels.php` | Section-label regex + EN↔DE name mapping (Verse↔Strophe, Chorus↔Refrain, etc.) |
|
||||
| `app/Http/Controllers/CcliPasteController.php` | POST /api/ccli/preview + POST /api/songs/import-from-ccli-paste (3 modes) |
|
||||
| `app/Http/Controllers/BookmarkletController.php` | GET /bookmarklets/ccli-import.js — serves the bookmarklet JS |
|
||||
| `resources/js/Components/CcliPasteDialog.vue` | Modal: textarea → preview → import buttons (surface-aware) |
|
||||
| `resources/js/Pages/Songs/ImportFromCcliPaste.vue` | Bookmarklet redirect landing page |
|
||||
| `tests/Fixtures/ccli/` | 22 synthetic CCLI-format fixture files for parser tests |
|
||||
|
||||
### Test coverage
|
||||
|
||||
- `tests/Feature/CcliPasteParserTest.php` — parser against all 22 fixtures
|
||||
- `tests/Feature/CcliImportServiceTest.php` — upsert, duplicate, restore, transaction
|
||||
- `tests/Feature/CcliTranslationPairingServiceTest.php` — label pairing, line distribution
|
||||
- `tests/Feature/CcliPasteControllerTest.php` — API endpoints, auth, throttle
|
||||
- `tests/e2e/ccli-paste-import.spec.ts` — SongDB import flow
|
||||
- `tests/e2e/ccli-bookmarklet.spec.ts` — bookmarklet endpoint + Settings page
|
||||
- `tests/e2e/ccli-translation-pairing.spec.ts` — Translate.vue prefill
|
||||
|
||||
### Maintenance note
|
||||
|
||||
If SongSelect changes their HTML structure, update the bookmarklet DOM selectors in `resources/js/bookmarklet/ccli-import.ts` (or the inline JS in `BookmarkletController.php`). The server-side parser (`CcliPasteParser.php`) is independent of SongSelect's HTML — it only parses the plain-text lyrics format.
|
||||
|
||||
---
|
||||
|
||||
## 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 +105,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 +158,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 +186,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
|
||||
|
|
|
|||
429
CCLI-API.md
429
CCLI-API.md
|
|
@ -1,429 +0,0 @@
|
|||
# CCLI SongSelect Partner API — Doc Pointer
|
||||
|
||||
## Where to get the docs
|
||||
|
||||
**Postman documentation** (only public source, no PDF/OpenAPI mirror):
|
||||
https://documenter.getpostman.com/view/604633/TzseGkmA
|
||||
|
||||
The page is JS-rendered. Two ways to read it:
|
||||
1. Open in a browser (Chrome/Firefox), wait for the Postman documenter to render.
|
||||
2. Click the "Run in Postman" button top-right to import the full collection + environment into a Postman workspace — then inspect every endpoint, params, headers, sample requests/responses.
|
||||
|
||||
The collection name is **"SongSelect Partner API"** under owner id `604633`.
|
||||
|
||||
## Status (read first!)
|
||||
|
||||
> **NOTICE: CCLI has retired the SongSelect API Partner Program and is no longer accepting new API partners.**
|
||||
|
||||
Existing partners keep working. New access requires contacting CCLI directly (`partners@ccli.com` / regional CCLI office) to request reinstatement or special arrangement.
|
||||
|
||||
## Key facts (from the docs)
|
||||
|
||||
- **Auth**: OpenID Connect / OAuth 2.0, **Authorization Code with PKCE**, refresh tokens supported
|
||||
- Authorize: `https://identityservices.ccli.com/connect/authorize`
|
||||
- Token: `https://identityservices.ccli.com/connect/token`
|
||||
- Scope: `openid cclipartnerapi.read offline_access`
|
||||
- **Subscription Key**: every request needs header `Ocp-Apim-Subscription-Key: <key>` (dev key for testing, prod key for live)
|
||||
- **Tokens**: access token 1h, refresh token 60-day sliding (one-time use, new refresh returned on each refresh)
|
||||
- **Rate limits**: 100 calls / 10s short term, 300 calls / 5min long term. `429` returns JSON `{statusCode, message}`.
|
||||
- **Dev restrictions**: dev client only sees content for users linked to the "SongSelect API <country> Partners" test organization.
|
||||
- Endpoint reference (search, song detail, lyrics, chord chart, etc.) lives inside the Postman collection — load it to see exact paths/params, not summarized in the public preview.
|
||||
|
||||
## Credentials needed before coding
|
||||
|
||||
1. CCLI Partner ClientId + ClientSecret
|
||||
2. Development Subscription Key (Ocp-Apim-Subscription-Key)
|
||||
3. Production Subscription Key (later)
|
||||
4. A CCLI user account linked to the Partner test organization (for dev refresh-token bootstrap)
|
||||
|
||||
Store in `.env`:
|
||||
```
|
||||
CCLI_PARTNER_CLIENT_ID=
|
||||
CCLI_PARTNER_CLIENT_SECRET=
|
||||
CCLI_PARTNER_SUBSCRIPTION_KEY_DEV=
|
||||
CCLI_PARTNER_SUBSCRIPTION_KEY_PROD=
|
||||
CCLI_PARTNER_REDIRECT_URI=https://pp-planer.ddev.site/oauth/ccli/callback
|
||||
```
|
||||
|
||||
## Bootstrap flow for a new agent
|
||||
|
||||
1. Load Postman collection from URL above → list every endpoint with its path, params, sample response.
|
||||
2. Mirror existing `ChurchToolsService` pattern (`app/Services/ChurchToolsService.php`) — closure-injectable fetcher, `logApiCall`, `classifyError`, German error messages, `ApiRequestLog` row per call.
|
||||
3. Implement OAuth2 PKCE handshake → persist refresh token (encrypted) in a `ccli_tokens` table. Auto-refresh on 401.
|
||||
4. Always send `Ocp-Apim-Subscription-Key` header alongside `Authorization: Bearer <access_token>`.
|
||||
5. Respect rate limits (Laravel `RateLimiter::for('ccli', ...)` with 100/10s + 300/5min buckets).
|
||||
6. Map result to existing schema: `Song.ccli_id`, arrangements + global `Label`s (Strophe 1 / Refrain / Bridge), `SongSlide.text_content`. See `ProImportService::upsertSong` for the upsert template.
|
||||
|
||||
## Fallback if API access denied
|
||||
|
||||
- Manual paste flow → parser splits on `Verse N`, `Chorus`, `Bridge`, `Pre-Chorus`, `Tag`, `Ending` headings.
|
||||
- `.pro` import already implemented (`POST /api/songs/import-pro`).
|
||||
|
||||
---
|
||||
|
||||
# Alternative: Headless-browser scraping (NO official API)
|
||||
|
||||
Use this when the Partner API is not available (current default for new projects). It drives `songselect.ccli.com` with a real browser session using a normal CCLI SongSelect subscription. Same data the user would download manually, just automated.
|
||||
|
||||
## ToS / legal note
|
||||
|
||||
CCLI's SongSelect ToS forbids "automated retrieval" without partner agreement. A church-internal tool that only acts on behalf of an authenticated subscriber and respects rate limits is a gray area many open-source projects (OpenLP, FreeShow community fork, `gwonamfromkoradai/SongSelectSave`) operate in. Document the risk in `README` and let the church decide.
|
||||
|
||||
## Required credentials
|
||||
|
||||
```
|
||||
CCLI_SONGSELECT_USER= # CCLI account email
|
||||
CCLI_SONGSELECT_PASSWORD= # CCLI account password
|
||||
CCLI_SONGSELECT_BASE_URL=https://songselect.ccli.com
|
||||
```
|
||||
|
||||
Single shared app account (chosen). Encrypt the password at rest (`Crypt::encryptString`) — never log it.
|
||||
|
||||
## Tech stack pick
|
||||
|
||||
Three viable headless-browser options for Laravel:
|
||||
|
||||
| Tool | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **`spatie/browsershot`** (Puppeteer + Chromium via Node) | Already in Laravel ecosystem; simple PHP API; supports cookies, headers, screenshots | Heavyweight; needs Node + Chromium in container |
|
||||
| **`laravel/dusk`** (ChromeDriver) | Pure Laravel; auth helpers; assertion DSL | Built for testing, awkward for prod scraping |
|
||||
| **Playwright via Node side-script** (`tests/e2e` already uses it) | Best automation API; persistent storage state; identical to existing E2E setup | Crosses PHP↔Node boundary (CLI exec or queue worker) |
|
||||
|
||||
**Recommendation: Playwright** — already a dev dep, `tests/e2e/auth.setup.ts` proves the pattern. Run as a queue job that shells out to a Node script, returns JSON.
|
||||
|
||||
DDEV needs Chromium installed — add to `.ddev/web-build/Dockerfile.example`:
|
||||
```dockerfile
|
||||
RUN apt-get update && apt-get install -y chromium fonts-liberation
|
||||
RUN npx --yes playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
## Endpoints / DOM contract (observed)
|
||||
|
||||
These are not an "API" — they are URL + selector contracts that can change. Re-verify quarterly.
|
||||
|
||||
### 1. Login
|
||||
- URL: `https://profile.ccli.com/account/signin?appContext=SongSelect`
|
||||
- Form fields: `input[name="EmailAddress"]`, `input[name="Password"]`, `button[type="submit"]`
|
||||
- Success: redirect to `https://songselect.ccli.com/`
|
||||
- Persist cookies (`profile.ccli.com`, `songselect.ccli.com`) in `storage/app/ccli/state.json` (Playwright `storageState`). Re-login when cookies expire.
|
||||
|
||||
### 2. Search by keyword
|
||||
- URL: `https://songselect.ccli.com/search/results?Keyword={url-encoded-query}`
|
||||
- Result rows: `.song-result` (or current class — verify with DevTools)
|
||||
- Fields per row: `.song-title a` (link + title), `.song-authors` (authors), `.song-ccli-number` or attribute `data-id` (CCLI #)
|
||||
- Pagination: `?Keyword=...&CurrentPage=2`
|
||||
|
||||
### 3. Search by CCLI number
|
||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}` → redirects to canonical song page
|
||||
|
||||
### 4. Song detail
|
||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}`
|
||||
- Metadata in `<dl>` or schema.org JSON-LD `<script type="application/ld+json">` (preferred — stable):
|
||||
- `name` → title
|
||||
- `author[].name` → authors
|
||||
- `copyrightYear`, `copyrightHolder`
|
||||
- Themes / publishers in side panel.
|
||||
|
||||
### 5. Lyrics download (the "parts" the user wants)
|
||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}/viewlyrics`
|
||||
- Trigger: click `#lyricsDownloadButton` (gives `.txt`) OR fetch hidden link `a[data-download-format="txt"]`
|
||||
- The `.txt` payload is **structured by part**, e.g.:
|
||||
```
|
||||
Verse 1
|
||||
Amazing grace, how sweet the sound
|
||||
...
|
||||
|
||||
Chorus
|
||||
My chains are gone...
|
||||
|
||||
Verse 2
|
||||
...
|
||||
|
||||
Bridge
|
||||
...
|
||||
|
||||
CCLI Song # 22025
|
||||
© Public Domain
|
||||
CCLI License # 12345
|
||||
```
|
||||
- Headers to detect (regex): `^(Verse \d+|Chorus( \d+)?|Pre-Chorus|Bridge( \d+)?|Tag|Ending|Intro|Interlude|Refrain|Coda)\s*$`
|
||||
|
||||
### 6. ChordPro download (optional, if account has chord access)
|
||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}/chordpro` → click `.chordpro-download`
|
||||
- Format is industry-standard ChordPro — easier to parse than HTML.
|
||||
|
||||
## Mapping to existing schema
|
||||
|
||||
```
|
||||
SongSelect part header → global Label name
|
||||
─────────────────────────────────────────────
|
||||
Verse N → Strophe N
|
||||
Chorus / Refrain → Refrain
|
||||
Pre-Chorus → Pre-Refrain
|
||||
Bridge → Bridge
|
||||
Tag / Ending / Coda → Outro
|
||||
Intro / Interlude → Intro / Zwischenspiel
|
||||
```
|
||||
|
||||
Lookup labels case-insensitive (`SongService::createDefaultGroups` already does `LOWER(name)`); create new global label if no match.
|
||||
|
||||
Persistence template (mirror `ProImportService::upsertSong`):
|
||||
1. `Song::firstOrNew(['ccli_id' => $ccliId])` — restore soft-deleted via `restore()`
|
||||
2. Update title / author / copyright_text / copyright_year / publisher
|
||||
3. Wipe existing arrangements for clean re-import (or skip if user opted "merge")
|
||||
4. Create one `SongArrangement(name='Normal', is_default=true)`
|
||||
5. For each parsed part → find/create `Label`, create `SongSlide(label_id, order, text_content)`, attach via `SongArrangementLabel(order)`
|
||||
|
||||
## Service skeleton
|
||||
|
||||
```php
|
||||
// app/Services/SongSelectScraperService.php
|
||||
final class SongSelectScraperService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SongImportService $importer,
|
||||
) {}
|
||||
|
||||
public function search(string $query): Collection { /* runs node script: search */ }
|
||||
|
||||
public function fetchByCcliId(int $ccliId): array { /* runs node script: detail+lyrics */ }
|
||||
|
||||
public function importToDb(int $ccliId): Song
|
||||
{
|
||||
$payload = $this->fetchByCcliId($ccliId);
|
||||
return $this->importer->upsertFromSongSelect($payload); // mirrors ProImportService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run scraper inside a queue job (`ScrapeSongSelectJob`) — never block HTTP request. Frontend polls or uses Inertia partial reload.
|
||||
|
||||
## Node side-script (Playwright)
|
||||
|
||||
`scripts/songselect-fetch.mjs`:
|
||||
```js
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const [, , action, arg] = process.argv; // e.g. 'search' 'amazing grace' OR 'detail' 22025
|
||||
const STATE = 'storage/app/ccli/state.json';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = fs.existsSync(STATE)
|
||||
? await browser.newContext({ storageState: STATE })
|
||||
: await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// auto-login if cookies missing
|
||||
await page.goto('https://songselect.ccli.com/');
|
||||
if (await page.locator('text=Sign In').isVisible().catch(() => false)) {
|
||||
await page.goto('https://profile.ccli.com/account/signin?appContext=SongSelect');
|
||||
await page.fill('input[name="EmailAddress"]', process.env.CCLI_SONGSELECT_USER);
|
||||
await page.fill('input[name="Password"]', process.env.CCLI_SONGSELECT_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('**/songselect.ccli.com/**');
|
||||
await ctx.storageState({ path: STATE });
|
||||
}
|
||||
|
||||
let result;
|
||||
if (action === 'search') {
|
||||
await page.goto(`https://songselect.ccli.com/search/results?Keyword=${encodeURIComponent(arg)}`);
|
||||
result = await page.$$eval('.song-result', rows => rows.map(r => ({
|
||||
ccli_id: r.dataset.id ?? r.querySelector('.song-ccli-number')?.textContent?.trim(),
|
||||
title: r.querySelector('.song-title')?.textContent?.trim(),
|
||||
authors: r.querySelector('.song-authors')?.textContent?.trim(),
|
||||
url: r.querySelector('a')?.href,
|
||||
})));
|
||||
} else if (action === 'detail') {
|
||||
await page.goto(`https://songselect.ccli.com/Songs/${arg}`);
|
||||
const url = page.url();
|
||||
const meta = await page.$eval('script[type="application/ld+json"]', s => JSON.parse(s.textContent));
|
||||
await page.goto(url.replace(/\/?$/, '/viewlyrics'));
|
||||
const lyrics = await page.locator('pre, .lyrics-content').innerText();
|
||||
result = { ccli_id: arg, ...meta, lyrics };
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
PHP side calls via `Symfony\Component\Process\Process` and decodes JSON.
|
||||
|
||||
## Lyrics → parts parser (PHP)
|
||||
|
||||
```php
|
||||
final class SongSelectLyricsParser
|
||||
{
|
||||
private const HEADER = '/^(Verse \d+|Chorus(?: \d+)?|Pre-Chorus|Bridge(?: \d+)?|Tag|Ending|Intro|Interlude|Refrain|Coda)\s*$/i';
|
||||
private const LABEL_MAP = [
|
||||
'verse' => 'Strophe', // suffix the number
|
||||
'chorus' => 'Refrain',
|
||||
'refrain' => 'Refrain',
|
||||
'pre-chorus' => 'Pre-Refrain',
|
||||
'bridge' => 'Bridge',
|
||||
'tag' => 'Outro',
|
||||
'ending' => 'Outro',
|
||||
'coda' => 'Outro',
|
||||
'intro' => 'Intro',
|
||||
'interlude' => 'Zwischenspiel',
|
||||
];
|
||||
|
||||
/** @return array<int, array{label: string, text: string}> */
|
||||
public function parse(string $raw): array { /* split on HEADER, map via LABEL_MAP */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Rate limiting & politeness
|
||||
|
||||
- Cap to **30 requests/minute** per app instance (`RateLimiter::for('ccli-scrape', fn () => Limit::perMinute(30))`).
|
||||
- One concurrent scrape job (`ScrapeSongSelectJob` with `WithoutOverlapping` middleware).
|
||||
- Cache result for 30 days (`songs.ccli_id` already keyed). User can force-refresh via "Re-import" button.
|
||||
- Random jitter 500-1500ms between page loads.
|
||||
|
||||
## UI integration
|
||||
|
||||
1. **`Songs/Index.vue`** — top-bar search input "CCLI Lookup" → `POST /api/ccli/search { q }` → modal with results → "Import" button per row.
|
||||
2. **`SongAgendaItem.vue`** (unmatched row) — new button "SongSelect suchen" next to existing Request/Assign → opens same modal pre-filled with CTS song name.
|
||||
3. **Preview modal before save** — show parsed parts grouped by detected Label, allow drag-reassign / rename, then confirm import.
|
||||
4. All German text, Du-form: "Suche bei CCLI…", "Importieren", "Als Strophe 1 zuweisen", etc.
|
||||
|
||||
## Failure modes & detection
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Redirect to `/account/signin` mid-session | Cookie expired | Re-run login flow, retry once |
|
||||
| Empty `.song-result` list | DOM changed OR query 0 hits | Save HTML snapshot to `storage/logs/ccli/` for inspection |
|
||||
| HTTP 429 / "Too many requests" page | Rate limit hit | Back off 5min, alert admin |
|
||||
| Captcha (`recaptcha` iframe) | CCLI flagged automation | Stop, surface admin notice, fall back to manual paste |
|
||||
| Login fails | Wrong creds OR account suspended | German error to admin |
|
||||
|
||||
Log every scrape into `api_request_logs` (existing table) with `service='songselect'` so the existing log UI shows them alongside CTS calls.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit-test the parser with fixtures in `tests/Fixtures/songselect/*.txt`.
|
||||
- Mock the Playwright invocation in service tests via constructor closure (mirror `ChurchToolsService` pattern).
|
||||
- E2E test against a sandbox public-domain song (e.g. CCLI #22025 "Amazing Grace") — gated by `CCLI_SONGSELECT_USER` env, skip if missing.
|
||||
|
||||
## Bootstrap checklist for a new agent
|
||||
|
||||
1. Confirm CCLI subscription credentials are in `.env`.
|
||||
2. Add Chromium to DDEV web container.
|
||||
3. Create `scripts/songselect-fetch.mjs`.
|
||||
4. Create `app/Services/SongSelectScraperService.php` + `SongSelectLyricsParser.php` + `SongImportService::upsertFromSongSelect()` (refactor common parts out of `ProImportService`).
|
||||
5. Create `ScrapeSongSelectJob` (queued, `WithoutOverlapping`).
|
||||
6. Add routes `POST /api/ccli/search`, `POST /api/ccli/import/{ccliId}`.
|
||||
7. Add Vue search modal + integrate into `Songs/Index.vue` + `SongAgendaItem.vue`.
|
||||
8. Write parser unit tests + service feature test (mock Process).
|
||||
9. Document the ToS gray area in README.
|
||||
|
||||
---
|
||||
|
||||
# Reference: How OpenLP imports from CCLI
|
||||
|
||||
Source: `openlp/plugins/songs/lib/songselect.py` on https://gitlab.com/openlp/openlp (LGPL).
|
||||
|
||||
**Approach: embedded Qt WebEngine (= real Chromium) + JS injection**
|
||||
|
||||
OpenLP does NOT do headless HTTP scraping. It opens a `QWebEngineView` (PySide6 Qt Chromium) inside the desktop app on `https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https%3a%2f%2fsongselect.ccli.com%2f`. The user signs in **manually** in that embedded browser (so they solve any captcha themselves). After login the same webview holds the authenticated cookies.
|
||||
|
||||
OpenLP then drives the page via `webview.page().runJavaScript(...)` to:
|
||||
|
||||
1. Detect current page by URL (`Login` / `Home` / `Search` / `Song` / `Other`).
|
||||
2. Navigate by setting `document.location = "<url>"`.
|
||||
3. Pre-fill login fields:
|
||||
```js
|
||||
document.getElementById("EmailAddress").value = "<email>";
|
||||
document.getElementById("Password").value = "<password>";
|
||||
```
|
||||
(User still clicks Sign-In manually so Turnstile sees a real interaction.)
|
||||
4. **Fetch any URL with the page's session cookies** by injecting:
|
||||
```js
|
||||
var openlp_page_data = null;
|
||||
fetch("<url>")
|
||||
.then(r => r.text())
|
||||
.then(t => { openlp_page_data = t; });
|
||||
```
|
||||
then polls `openlp_page_data != null` and reads the result back into Python. This is the clever bit — they bypass cookie-export entirely, using the already-authenticated browser context as the HTTP client.
|
||||
5. Parse HTML → song dict → write into the OpenLP DB via SQLAlchemy (`Song`, `Author`, `Topic`, `SongXML` verses with `VerseType.tags`).
|
||||
|
||||
URL constants in OpenLP:
|
||||
```python
|
||||
BASE_URL = 'https://songselect.ccli.com'
|
||||
LOGIN_PAGE = 'https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https%3a%2f%2fsongselect.ccli.com%2f'
|
||||
LOGIN_URL = 'https://profile.ccli.com'
|
||||
LOGOUT_URL = BASE_URL + '/account/logout'
|
||||
SEARCH_URL = BASE_URL + '/search/results'
|
||||
SONG_PAGE = BASE_URL + '/Songs/'
|
||||
CCLI_NUMBER_REGEX = r'.*?Songs\/([0-9]+).*'
|
||||
```
|
||||
|
||||
**Lesson for a Laravel server-side port**: OpenLP succeeds because it ships a full GUI Chromium and pushes the captcha problem onto the user. A server-side scraper has to solve the same captcha non-interactively — see next section.
|
||||
|
||||
# Cloudflare Turnstile on CCLI login (verified 2026-05)
|
||||
|
||||
Confirmed by fetching `https://profile.ccli.com/account/signin?appContext=SongSelect`:
|
||||
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
|
||||
<div class="cf-turnstile sr-only"
|
||||
data-sitekey="0x4AAAAAAA1USwfe0YamenZA"
|
||||
data-appearance="interaction-only"
|
||||
data-callback="enableSubmit" inert></div>
|
||||
```
|
||||
|
||||
- **Mode**: `interaction-only` (Managed/Invisible — silent unless trust score drops, then escalates to checkbox click)
|
||||
- **Sitekey**: `0x4AAAAAAA1USwfe0YamenZA`
|
||||
- **Submit button is disabled until Turnstile callback fires**, then a hidden `cf-turnstile-response` input is added to the POST body
|
||||
- Form also includes ASP.NET `__RequestVerificationToken` (CSRF) — must be scraped from the GET response and sent back
|
||||
- CCLI also injects **Cloudflare Bot Management JSD** (`/cdn-cgi/challenge-platform/scripts/jsd/main.js`) — additional passive fingerprinting on every page
|
||||
|
||||
## Can Turnstile be bypassed WITHOUT a real Chrome?
|
||||
|
||||
**Short answer: No.** Turnstile requires a JavaScript runtime + canvas + WebGL + AudioContext + matching TLS/JA3 fingerprint to mint a valid token. A real browser engine must run somewhere — locally, in a queue worker, or in the cloud.
|
||||
|
||||
The realistic option matrix:
|
||||
|
||||
| Approach | "Real Chrome" needed? | Cost | Reliability for CCLI | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Pure HTTP** (Guzzle / curl / requests) | none | free | **Will not work** | Cannot execute the Turnstile JS that mints the token. Hard wall. |
|
||||
| **`curl-impersonate` / `curl_cffi`** (TLS-fingerprint spoofing) | none | free | **Will not work alone** | Solves JA3 fingerprint but still no JS engine for the Turnstile widget. Useful only AFTER a session cookie exists. |
|
||||
| **Patched headless Chromium** (Playwright + `playwright-stealth`, `puppeteer-extra-plugin-stealth`, `nodriver`, `patchright`) | yes (local) | free | **Medium** for `interaction-only` mode | Stealth plugins hide `navigator.webdriver`, fix canvas/WebGL leaks. Often passes Turnstile silently. Breaks under residential-IP requirement or escalation to interactive. |
|
||||
| **`undetected-chromedriver` + SeleniumBase UC Mode** | yes (local) | free | **Medium-High** | Has built-in `uc_gui_click_captcha()` that uses pyautogui to click the checkbox if Turnstile escalates. Python-only. |
|
||||
| **Camoufox** (patched Firefox, fingerprint injection at C++ level) | yes (local) | free | **Medium-High** | Different signature from Chromium-based detection profiles; useful when stealth-Chromium gets flagged. |
|
||||
| **CAPTCHA-solving service** (2Captcha, CapSolver, NextCaptcha, Anti-Captcha) | none locally; service runs browsers | ≈$1.45/1k tokens | **Low for CCLI specifically** | They return a Turnstile token bound to the sitekey + your IP. CCLI also fingerprints the browser env + JSD beacon, so token alone often fails to authenticate. Token TTL ≈ 5min, single-use. |
|
||||
| **Cloud browser API** (Scrapfly ASP, Browserless, Bright Data Scraping Browser, Scrapeless, ZenRows, Oxylabs Web Unblocker) | yes (remote) | ≈$5-50/1k pages | **High** | Real Chromium + residential proxy + automatic challenge solving in one call. The only "no local Chrome" option that actually works at scale. |
|
||||
| **Manual one-time login + persisted cookies** (OpenLP model) | yes (one-time, in user's own browser) | free | **High** | User logs in once via popup/embedded view, app stores `.AspNet.ApplicationCookie` + Cloudflare `cf_clearance` cookies, reuses them for HTTP scraping until they expire (typically 30 days; `cf_clearance` is shorter ≈ 1 hour but auto-refreshes if you keep the same browser fingerprint via `curl-impersonate`). |
|
||||
|
||||
**`cf_clearance` cookie pitfall**: even with a valid `.AspNet.ApplicationCookie`, Cloudflare checks `cf_clearance` on every request and ties it to the originating browser's TLS+UA fingerprint. Reusing the cookie from raw `curl` will give `403 / cf_chl_*` because the JA3 fingerprint won't match. Use `curl-impersonate-chrome` or `curl_cffi` (`curl_cffi.requests` with `impersonate="chrome120"`) so the TLS handshake matches the browser that minted the cookie.
|
||||
|
||||
## Recommended architecture for pp-planer
|
||||
|
||||
Hybrid that mirrors OpenLP's user-driven login but server-side scraping:
|
||||
|
||||
1. **Admin panel "CCLI Session" page**
|
||||
- "Sign in to CCLI" button opens a popup window pointed at `https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https://pp-planer.ddev.site/api/ccli/oauth-callback`.
|
||||
- User logs in normally. Their own browser handles Turnstile (silent in 99% of cases for residential IPs).
|
||||
- On the redirect back to our callback, JS reads `document.cookie` from the popup (only works for cookies on **our** domain — see below) — so this approach actually requires a different mechanism.
|
||||
|
||||
2. **Better: bundled headless browser inside a queue worker**
|
||||
- Use Playwright (already a dev dep) + `playwright-extra` + `playwright-extra-plugin-stealth` in headed mode for first login, headless for re-use.
|
||||
- Persist `storageState` to `storage/app/ccli/state.json` (encrypted at rest).
|
||||
- First-time setup: admin runs `php artisan ccli:login` → opens a non-headless Playwright browser on the server's display (or via VNC/X11 forwarding in DDEV) → admin types credentials and solves any escalated Turnstile checkbox.
|
||||
- All subsequent fetches use saved cookies in headless mode. Re-prompt admin when cookies expire.
|
||||
|
||||
3. **For ongoing fetches**: once authenticated, can drop down to `curl_cffi`-style HTTP via Symfony HttpClient with a Chrome JA3 fingerprint (PHP package: `quic-go/curl-impersonate` shell-out, or call Node `curl-impersonate` script) — much faster than re-launching browser per request.
|
||||
|
||||
4. **Fallback if Turnstile escalates beyond stealth limits**: route through a cloud browser (Scrapfly ASP `asp=true` flag handles it). Make it pluggable behind `SongSelectClient` interface.
|
||||
|
||||
## Honest recommendation
|
||||
|
||||
For a church-internal tool used by a handful of staff, scraping at all is overkill. Realistic ranking:
|
||||
|
||||
1. **Manual paste flow** + lyric parser → 2 days of work, zero external deps, zero ToS risk.
|
||||
2. **`.pro` import** (already done) — staff can download `.pro` files from SongSelect manually and drop them in the existing upload area.
|
||||
3. **OpenLP-style embedded webview** — only works for desktop; doesn't fit a Laravel web app.
|
||||
4. **Server-side stealth Playwright + persisted cookies** — works, but ~1-2 weeks of fragile glue code, breaks every CCLI redesign or Cloudflare ruleset bump.
|
||||
5. **Cloud browser API (Scrapfly etc.)** — most reliable, costs €€, still ToS-gray.
|
||||
|
||||
If automation is mandatory: option 4 with option 5 as fallback when the local browser fails.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class DuplicateCcliSongException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $existingSongId,
|
||||
string $message = '',
|
||||
) {
|
||||
parent::__construct($message ?: "Song mit dieser CCLI-Nummer existiert bereits (#{$existingSongId})");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,8 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
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;
|
||||
|
|
@ -25,24 +23,17 @@ public function store(Request $request, Song $song): RedirectResponse
|
|||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$defaultArr = $song->arrangements()->where('is_default', true)->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get();
|
||||
|
||||
$rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [
|
||||
$groups = $song->groups()->orderBy('order')->get();
|
||||
$rows = $groups->map(fn ($group, $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'song_group_id' => $group->id,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementSections()->insert($rows);
|
||||
$arrangement->arrangementGroups()->insert($rows);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -56,14 +47,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
|
|||
]);
|
||||
|
||||
DB::transaction(function () use ($arrangement, $data): void {
|
||||
$arrangement->loadMissing('arrangementSections');
|
||||
$arrangement->loadMissing('arrangementGroups');
|
||||
|
||||
$clone = $arrangement->song->arrangements()->create([
|
||||
'name' => $data['name'],
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$this->cloneArrangementLabels($arrangement, $clone);
|
||||
$this->cloneGroups($arrangement, $clone);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde geklont.');
|
||||
|
|
@ -73,23 +64,33 @@ 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.*.song_group_id' => ['required', 'integer', 'exists:song_groups,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'] ?? []);
|
||||
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
|
||||
$uniqueGroupIds = $groupIds->unique()->values();
|
||||
|
||||
DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
|
||||
$arrangement->arrangementSections()->delete();
|
||||
$validGroupIds = $arrangement->song->groups()
|
||||
->whereIn('id', $uniqueGroupIds)
|
||||
->pluck('id');
|
||||
|
||||
$rows = $sectionIds
|
||||
if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($arrangement, $groupIds, $data): void {
|
||||
$arrangement->arrangementGroups()->delete();
|
||||
|
||||
$rows = $groupIds
|
||||
->values()
|
||||
->map(fn (int $sectionId, int $index) => [
|
||||
->map(fn (int $songGroupId, int $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $sectionId,
|
||||
'song_group_id' => $songGroupId,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
|
@ -97,19 +98,14 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementSections()->insert($rows);
|
||||
$arrangement->arrangementGroups()->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 $groupId => $color) {
|
||||
$arrangement->song->groups()
|
||||
->whereKey((int) $groupId)
|
||||
->update(['color' => $color]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -140,62 +136,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
|
|||
return back()->with('success', 'Arrangement wurde gelöscht.');
|
||||
}
|
||||
|
||||
private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void
|
||||
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
|
||||
{
|
||||
if ($source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$arrangementSections = $source->arrangementSections
|
||||
$groups = $source->arrangementGroups
|
||||
->sortBy('order')
|
||||
->values();
|
||||
|
||||
$rows = $arrangementSections
|
||||
->map(fn ($arrangementSection) => [
|
||||
$rows = $groups
|
||||
->map(fn ($arrangementGroup) => [
|
||||
'song_arrangement_id' => $target->id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $arrangementSection->order,
|
||||
'song_group_id' => $arrangementGroup->song_group_id,
|
||||
'order' => $arrangementGroup->order,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$target->arrangementSections()->insert($rows);
|
||||
$target->arrangementGroups()->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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
$appUrl = rtrim($request->getSchemeAndHttpHost(), '/');
|
||||
|
||||
if ($appUrl === '') {
|
||||
$appUrl = rtrim((string) Config::get('app.url', ''), '/');
|
||||
}
|
||||
|
||||
$bookmarkletScript = <<<'BOOKMARKLET'
|
||||
(function(){
|
||||
var APP_URL = '__APP_URL__';
|
||||
if(!location.hostname.includes('songselect.ccli.com')){
|
||||
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 payload = {
|
||||
title: '',
|
||||
author: '',
|
||||
ccliId: ccliMatch ? ccliMatch[1] : '',
|
||||
sourceUrl: location.href,
|
||||
rawText: text || ''
|
||||
};
|
||||
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
|
||||
window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank');
|
||||
}
|
||||
var btn = document.querySelector('#generalCopyLyricsButton');
|
||||
if(!btn){
|
||||
alert('Kopier-Symbol nicht gefunden. Bitte öffne die Liedtext-Ansicht auf SongSelect und versuche es erneut.');
|
||||
return;
|
||||
}
|
||||
var captured = null;
|
||||
function onCopy(e){
|
||||
try { captured = e.clipboardData.getData('text/plain'); } catch(err) {}
|
||||
}
|
||||
document.addEventListener('copy', onCopy, true);
|
||||
btn.click();
|
||||
setTimeout(function(){
|
||||
document.removeEventListener('copy', onCopy, true);
|
||||
if(captured && captured.trim()){
|
||||
send(captured);
|
||||
return;
|
||||
}
|
||||
if(navigator.clipboard && navigator.clipboard.readText){
|
||||
navigator.clipboard.readText().then(function(text){ send(text); })
|
||||
.catch(function(){ alert('Liedtext konnte nicht aus der Zwischenablage gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".'); });
|
||||
} else {
|
||||
alert('Liedtext konnte nicht gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".');
|
||||
}
|
||||
}, 250);
|
||||
})();
|
||||
BOOKMARKLET;
|
||||
|
||||
$bookmarkletScript = str_replace('__APP_URL__', $appUrl, $bookmarkletScript);
|
||||
|
||||
$singleLine = 'javascript:'.preg_replace('/\s+/', ' ', $bookmarkletScript);
|
||||
|
||||
return response($singleLine, 200, [
|
||||
'Content-Type' => 'text/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\DuplicateCcliSongException;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Services\CcliImportService;
|
||||
use App\Services\CcliPasteParser;
|
||||
use App\Services\CcliTranslationPairingService;
|
||||
use App\Services\SongMatchingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CcliPasteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
private readonly CcliImportService $importService,
|
||||
private readonly CcliTranslationPairingService $pairingService,
|
||||
private readonly SongMatchingService $matchingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/ccli/preview
|
||||
* Parse raw text and return DTO as JSON. No DB writes.
|
||||
*/
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'raw_text' => ['required', 'string', 'max:200000'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$parsed = $this->parser->parse($validated['raw_text']);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'title' => $parsed->title,
|
||||
'author' => $parsed->author,
|
||||
'ccliId' => $parsed->ccliId,
|
||||
'year' => $parsed->year,
|
||||
'copyrightText' => $parsed->copyrightText,
|
||||
'sections' => array_map(fn ($s) => [
|
||||
'label' => $s->label,
|
||||
'kind' => $s->kind,
|
||||
'number' => $s->number,
|
||||
'modifier' => $s->modifier,
|
||||
'lines' => $s->lines,
|
||||
'hasTranslation' => $s->linesTranslated !== null,
|
||||
], $parsed->sections),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/songs/import-from-ccli-paste
|
||||
* Import a CCLI paste in one of 3 modes.
|
||||
*/
|
||||
public function importPaste(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'raw_text' => ['required', 'string', 'max:200000'],
|
||||
'mode' => ['required', Rule::in(['create', 'pair-with-song', 'assign-to-service-song'])],
|
||||
'target_id' => ['required_unless:mode,create', 'nullable', 'integer'],
|
||||
'source_url' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$rawText = $validated['raw_text'];
|
||||
$mode = $validated['mode'];
|
||||
$targetId = $validated['target_id'] ?? null;
|
||||
$sourceUrl = $validated['source_url'] ?? null;
|
||||
|
||||
try {
|
||||
if ($mode === 'create') {
|
||||
$result = $this->importService->import($rawText, $sourceUrl);
|
||||
|
||||
return response()->json([
|
||||
'song_id' => $result['song']->id,
|
||||
'status' => $result['status'],
|
||||
'warnings' => $result['warnings'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($mode === 'pair-with-song') {
|
||||
$localSong = Song::findOrFail($targetId);
|
||||
$result = $this->pairingService->pair($localSong, $rawText);
|
||||
|
||||
session()->flash('ccli_prefilled', $result['distributed_text']);
|
||||
|
||||
return response()->json([
|
||||
'song_id' => $localSong->id,
|
||||
'mapping' => $result['mapping'],
|
||||
'unmatched_labels' => $result['unmatched_labels'],
|
||||
'redirect_to' => route('songs.translate', $localSong->id).'?prefilled=true',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($mode === 'assign-to-service-song') {
|
||||
$result = $this->importService->import($rawText, $sourceUrl);
|
||||
$song = $result['song'];
|
||||
|
||||
$serviceSong = ServiceSong::findOrFail($targetId);
|
||||
$this->matchingService->manualAssign($serviceSong, $song);
|
||||
|
||||
return response()->json([
|
||||
'song_id' => $song->id,
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'status' => $result['status'],
|
||||
], 201);
|
||||
}
|
||||
} catch (DuplicateCcliSongException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
'existing_song_id' => $e->existingSongId,
|
||||
'edit_url' => route('songs.index').'#song-'.$e->existingSongId,
|
||||
], 409);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Unbekannter Modus'], 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /songs/import-from-ccli-paste
|
||||
* Render the Inertia page for bookmarklet redirect.
|
||||
*/
|
||||
public function showImportPage(Request $request): InertiaResponse
|
||||
{
|
||||
$prefill = $request->query('prefill');
|
||||
$prefilledText = null;
|
||||
$prefilledMetadata = null;
|
||||
$prefillError = null;
|
||||
|
||||
if ($prefill !== null && is_string($prefill)) {
|
||||
try {
|
||||
$decoded = base64_decode($prefill, strict: true);
|
||||
if ($decoded === false) {
|
||||
throw new InvalidArgumentException('Ungültige Kodierung');
|
||||
}
|
||||
$payload = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new InvalidArgumentException('Ungültiges Payload-Format');
|
||||
}
|
||||
|
||||
$prefilledText = $payload['rawText'] ?? null;
|
||||
$prefilledMetadata = [
|
||||
'title' => $payload['title'] ?? null,
|
||||
'author' => $payload['author'] ?? null,
|
||||
'ccliId' => $payload['ccliId'] ?? null,
|
||||
'sourceUrl' => $payload['sourceUrl'] ?? null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
$prefillError = 'Lesezeichen-Daten konnten nicht gelesen werden. Bitte den Liedtext manuell einfügen.';
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Songs/ImportFromCcliPaste', [
|
||||
'prefilledText' => $prefilledText,
|
||||
'prefilledMetadata' => $prefilledMetadata,
|
||||
'prefillError' => $prefillError,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\LabelsImportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class LabelImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LabelsImportService $importService,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'max:5120']]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getPathname();
|
||||
|
||||
try {
|
||||
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Labels-Datei ist.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'new' => $result->newCount,
|
||||
'updated' => $result->updatedCount,
|
||||
'total' => $result->totalInFile,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\MacroCollection;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class MacroAssignmentController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('Settings', [
|
||||
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
|
||||
'macros' => Macro::with('collections')->orderBy('name')->get(),
|
||||
'labels' => Label::orderBy('name')->get(),
|
||||
'collections' => MacroCollection::orderBy('name')->get(),
|
||||
'last_macros_import' => [
|
||||
'at' => Setting::get('macros_last_imported_at'),
|
||||
'filename' => Setting::get('macros_last_imported_filename'),
|
||||
],
|
||||
'last_labels_import' => [
|
||||
'at' => Setting::get('labels_last_imported_at'),
|
||||
'filename' => Setting::get('labels_last_imported_filename'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'macro_id' => ['required', 'integer', 'exists:macros,id'],
|
||||
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$assignment = MacroAssignment::create($validated);
|
||||
|
||||
return response()->json(['id' => $assignment->id, 'success' => true]);
|
||||
}
|
||||
|
||||
public function update(Request $request, MacroAssignment $macroAssignment): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'macro_id' => ['sometimes', 'integer', 'exists:macros,id'],
|
||||
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['sometimes', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$macroAssignment->update($validated);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy(MacroAssignment $macroAssignment): JsonResponse
|
||||
{
|
||||
$macroAssignment->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'assignments' => ['required', 'array'],
|
||||
'assignments.*.id' => ['required', 'integer', 'exists:macro_assignments,id'],
|
||||
'assignments.*.order' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
foreach ($validated['assignments'] as $item) {
|
||||
MacroAssignment::where('id', $item['id'])->update(['order' => $item['order']]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\MacrosImportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class MacroImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MacrosImportService $importService,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'max:5120']]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getPathname();
|
||||
|
||||
try {
|
||||
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Makro-Datei ist.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => [
|
||||
'new' => $result->new,
|
||||
'updated' => $result->updated,
|
||||
'disabled' => $result->disabled,
|
||||
're_enabled' => $result->reEnabled,
|
||||
],
|
||||
'warnings' => $result->warnings,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,23 +49,15 @@ public function importPro(Request $request): JsonResponse
|
|||
|
||||
public function downloadPro(Song $song): BinaryFileResponse
|
||||
{
|
||||
if ($this->countSongLabels($song) === 0) {
|
||||
if ($song->groups()->count() === 0) {
|
||||
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
|
||||
}
|
||||
|
||||
$exportService = app(ProExportService::class);
|
||||
$exportService = new ProExportService;
|
||||
$tempPath = $exportService->generateProFile($song);
|
||||
|
||||
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||
|
||||
return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
private function countSongLabels(Song $song): int
|
||||
{
|
||||
return $song->arrangements()
|
||||
->withCount('arrangementLabels')
|
||||
->get()
|
||||
->sum('arrangement_labels_count');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Slide;
|
||||
use App\Models\Song;
|
||||
use App\Services\AgendaMatcherService;
|
||||
use App\Services\MacroResolutionService;
|
||||
use App\Services\ProBundleExportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -126,23 +124,23 @@ 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.groups',
|
||||
'serviceSongs.song.arrangements.arrangementGroups.group',
|
||||
'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.groups.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
|
||||
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
|
||||
]);
|
||||
|
||||
$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();
|
||||
|
||||
|
|
@ -230,41 +227,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
return $arr;
|
||||
}, $filteredItems);
|
||||
|
||||
// Macro resolution per part type (for icons + Anpassen/Standard panel)
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$macros_per_part = [];
|
||||
foreach (['information', 'moderation', 'sermon', 'song', 'agenda_item'] as $partType) {
|
||||
$assignments = $resolver->resolveAssignmentsForPart($service, $partType);
|
||||
$isOverridden = ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
->exists();
|
||||
$hasWarning = $assignments->contains(
|
||||
fn ($a) => $a->macro?->isHidden() || ($a->position === 'by_label' && $a->label?->isHidden())
|
||||
);
|
||||
$macros_per_part[$partType] = [
|
||||
'count' => $assignments->count(),
|
||||
'is_overridden' => $isOverridden,
|
||||
'has_warning' => $hasWarning,
|
||||
'assignments' => $assignments->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'macro_id' => $a->macro_id,
|
||||
'macro_name' => $a->macro?->name,
|
||||
'macro_color' => $a->macro?->color,
|
||||
'macro_hidden' => $a->macro?->isHidden(),
|
||||
'position' => $a->position,
|
||||
'label_id' => $a->label_id,
|
||||
'label_name' => $a->label?->name,
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// 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 +237,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,
|
||||
|
|
@ -299,7 +253,15 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'title' => $ss->song->title,
|
||||
'ccli_id' => $ss->song->ccli_id,
|
||||
'has_translation' => $ss->song->has_translation,
|
||||
'groups' => $this->collectSongLabels($ss->song),
|
||||
'groups' => $ss->song->groups
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
]),
|
||||
'arrangements' => $ss->song->arrangements
|
||||
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
|
||||
->values()
|
||||
|
|
@ -307,14 +269,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->arrangementGroups
|
||||
->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 ($arrangementGroup) => [
|
||||
'id' => $arrangementGroup->group?->id,
|
||||
'name' => $arrangementGroup->group?->name,
|
||||
'color' => $arrangementGroup->group?->color,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values(),
|
||||
|
|
@ -341,7 +302,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'title' => $nextService->title,
|
||||
'date' => $nextService->date?->toDateString(),
|
||||
] : null,
|
||||
'macros_per_part' => $macros_per_part,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -362,38 +322,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([
|
||||
|
|
@ -478,26 +412,4 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
|
|||
)
|
||||
->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
private function collectSongLabels(Song $song): \Illuminate\Support\Collection
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $defaultArr->arrangementSections
|
||||
->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,
|
||||
])
|
||||
->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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceMacroAssignment;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceMacroOverrideController extends Controller
|
||||
{
|
||||
public function store(Request $request, Service $service): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
]);
|
||||
|
||||
ServiceMacroOverride::firstOrCreate([
|
||||
'service_id' => $service->id,
|
||||
'part_type' => $validated['part_type'],
|
||||
]);
|
||||
|
||||
$globals = MacroAssignment::where('part_type', $validated['part_type'])->orderBy('order')->get();
|
||||
foreach ($globals as $global) {
|
||||
ServiceMacroAssignment::firstOrCreate([
|
||||
'service_id' => $service->id,
|
||||
'part_type' => $validated['part_type'],
|
||||
'macro_id' => $global->macro_id,
|
||||
'position' => $global->position,
|
||||
'label_id' => $global->label_id,
|
||||
'order' => $global->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy(Service $service, Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
]);
|
||||
|
||||
ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $validated['part_type'])
|
||||
->delete();
|
||||
|
||||
ServiceMacroAssignment::where('service_id', $service->id)
|
||||
->where('part_type', $validated['part_type'])
|
||||
->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function storeAssignment(Request $request, Service $service): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'macro_id' => ['required', 'integer', 'exists:macros,id'],
|
||||
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$assignment = ServiceMacroAssignment::create([
|
||||
'service_id' => $service->id,
|
||||
...$validated,
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $assignment->id, 'success' => true]);
|
||||
}
|
||||
|
||||
public function updateAssignment(Request $request, Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['sometimes', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$serviceMacroAssignment->update($validated);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroyAssignment(Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
|
||||
{
|
||||
$serviceMacroAssignment->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,70 +2,44 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\MacroCollection;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
private const AGENDA_KEYS = [
|
||||
private const MACRO_KEYS = [
|
||||
'macro_name',
|
||||
'macro_uuid',
|
||||
'macro_collection_name',
|
||||
'macro_collection_uuid',
|
||||
'agenda_start_title',
|
||||
'agenda_end_title',
|
||||
'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
|
||||
{
|
||||
$settings = [];
|
||||
foreach (self::AGENDA_KEYS as $key) {
|
||||
foreach (self::MACRO_KEYS as $key) {
|
||||
$settings[$key] = Setting::get($key);
|
||||
}
|
||||
|
||||
return Inertia::render('Settings', [
|
||||
'settings' => $settings,
|
||||
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
|
||||
'macros' => Macro::with('collections')->orderBy('name')->get(),
|
||||
'labels' => Label::orderBy('name')->get(),
|
||||
'collections' => MacroCollection::with('macros')->orderBy('name')->get(),
|
||||
'last_macros_import' => [
|
||||
'at' => Setting::get('macros_last_imported_at'),
|
||||
'filename' => Setting::get('macros_last_imported_filename'),
|
||||
],
|
||||
'last_labels_import' => [
|
||||
'at' => Setting::get('labels_last_imported_at'),
|
||||
'filename' => Setting::get('labels_last_imported_filename'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'key' => ['required', 'string', Rule::in(self::AGENDA_KEYS)],
|
||||
'key' => ['required', 'string', 'in:'.implode(',', self::MACRO_KEYS)],
|
||||
'value' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
if ($validated['key'] === 'default_translation_language') {
|
||||
validator($validated, [
|
||||
'value' => ['nullable', Rule::in(['DE', 'EN', 'FR', 'ES', 'NL', 'IT'])],
|
||||
])->validate();
|
||||
}
|
||||
|
||||
Setting::set($validated['key'], $validated['value']);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -16,10 +15,12 @@ public function __construct(
|
|||
private readonly SongService $songService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Alle Songs auflisten (paginiert, durchsuchbar).
|
||||
*/
|
||||
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 +29,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 +39,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(),
|
||||
|
|
@ -59,11 +53,15 @@ public function index(Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
|
||||
*/
|
||||
public function store(SongRequest $request): JsonResponse
|
||||
{
|
||||
$song = DB::transaction(function () use ($request) {
|
||||
$song = Song::create($request->validated());
|
||||
|
||||
$this->songService->createDefaultGroups($song);
|
||||
$this->songService->createDefaultArrangement($song);
|
||||
|
||||
return $song;
|
||||
|
|
@ -71,13 +69,16 @@ 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(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song mit Gruppen, Slides und Arrangements anzeigen.
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id);
|
||||
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
|
|
@ -88,6 +89,9 @@ public function show(int $id): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-Metadaten aktualisieren.
|
||||
*/
|
||||
public function update(SongRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$song = Song::find($id);
|
||||
|
|
@ -100,10 +104,13 @@ 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(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song soft-löschen.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::find($id);
|
||||
|
|
@ -119,37 +126,11 @@ public function destroy(int $id): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
public function formatSongDetail(Song $song): array
|
||||
/**
|
||||
* Song-Detail formatieren.
|
||||
*/
|
||||
private function formatSongDetail(Song $song): array
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true);
|
||||
|
||||
$groupsPayload = [];
|
||||
if ($defaultArr !== null) {
|
||||
$groupsPayload = $defaultArr->arrangementSections
|
||||
->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
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes,
|
||||
])->toArray()
|
||||
: [],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $song->id,
|
||||
'title' => $song->title,
|
||||
|
|
@ -163,25 +144,27 @@ public function formatSongDetail(Song $song): array
|
|||
'last_used_in_service' => $song->last_used_in_service,
|
||||
'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,
|
||||
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes,
|
||||
])->toArray(),
|
||||
])->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->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
|
||||
'id' => $ag->id,
|
||||
'song_group_id' => $ag->song_group_id,
|
||||
'order' => $ag->order,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -57,27 +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',
|
||||
'arrangementGroups' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
|
||||
]);
|
||||
|
||||
return $arrangement->arrangementSections->map(function ($arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
||||
if ($section === null || $label === null) {
|
||||
return null;
|
||||
}
|
||||
return $arrangement->arrangementGroups->map(function ($arrangementGroup) {
|
||||
$group = $arrangementGroup->group;
|
||||
|
||||
return [
|
||||
'name' => $label->name,
|
||||
'color' => $label->color ?? '#6b7280',
|
||||
'slides' => $section->slides->map(fn ($slide) => [
|
||||
'name' => $group->name,
|
||||
'color' => $group->color ?? '#6b7280',
|
||||
'slides' => $group->slides->map(fn ($slide) => [
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values()->all(),
|
||||
];
|
||||
})->filter()->values()->all();
|
||||
})->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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -18,52 +18,41 @@ public function __construct(
|
|||
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',
|
||||
'groups' => fn ($query) => $query
|
||||
->orderBy('order')
|
||||
->with([
|
||||
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
|
||||
]),
|
||||
]);
|
||||
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
$groups = collect();
|
||||
if ($defaultArr !== null) {
|
||||
$groups = $defaultArr->arrangementSections
|
||||
->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
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values()
|
||||
: collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
return Inertia::render('Songs/Translate', [
|
||||
'song' => [
|
||||
'id' => $song->id,
|
||||
'title' => $song->title,
|
||||
'ccli_id' => $song->ccli_id,
|
||||
'has_translation' => $song->has_translation,
|
||||
'groups' => $groups,
|
||||
'groups' => $song->groups->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
'slides' => $group->slides->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values(),
|
||||
])->values(),
|
||||
],
|
||||
'prefilledTranslation' => session()->pull('ccli_prefilled'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL abrufen und Text zum Prüfen zurückgeben.
|
||||
*
|
||||
* Der Text wird NICHT automatisch gespeichert — der Benutzer
|
||||
* prüft ihn zuerst und importiert dann explizit.
|
||||
*/
|
||||
public function fetchUrl(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
|
|
@ -83,6 +72,11 @@ public function fetchUrl(Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungstext für einen Song importieren.
|
||||
*
|
||||
* Verteilt den Text zeilenweise auf die Slides des Songs.
|
||||
*/
|
||||
public function import(int $songId, Request $request): JsonResponse
|
||||
{
|
||||
$song = Song::find($songId);
|
||||
|
|
@ -104,6 +98,9 @@ public function import(int $songId, Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzung eines Songs komplett entfernen.
|
||||
*/
|
||||
public function destroy(int $songId): JsonResponse
|
||||
{
|
||||
$song = Song::find($songId);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Label extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
'hidden_at',
|
||||
'last_imported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'hidden_at' => 'datetime',
|
||||
'last_imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Macro extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'color',
|
||||
'trigger_on_startup',
|
||||
'image_type',
|
||||
'action_count',
|
||||
'hidden_at',
|
||||
'last_imported_at',
|
||||
'last_imported_filename',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'trigger_on_startup' => 'boolean',
|
||||
'hidden_at' => 'datetime',
|
||||
'last_imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function collections(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(MacroCollection::class, 'macro_collection_macros')
|
||||
->withPivot('order')
|
||||
->orderBy('macro_collection_macros.order');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MacroAssignment::class);
|
||||
}
|
||||
|
||||
public function isHidden(): bool
|
||||
{
|
||||
return $this->hidden_at !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MacroAssignment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'part_type',
|
||||
'macro_id',
|
||||
'position',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function macro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Macro::class);
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class MacroCollection extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'last_imported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function macros(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Macro::class, 'macro_collection_macros')
|
||||
->withPivot('order')
|
||||
->orderBy('macro_collection_macros.order');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServiceMacroAssignment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'part_type',
|
||||
'macro_id',
|
||||
'position',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function macro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Macro::class);
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServiceMacroOverride extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'part_type',
|
||||
];
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceMacroAssignment::class, 'service_id', 'service_id')
|
||||
->where('part_type', $this->part_type);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ class Song extends Model
|
|||
protected $fillable = [
|
||||
'ccli_id',
|
||||
'cts_song_id',
|
||||
'imported_from_ccli_at',
|
||||
'ccli_source_url',
|
||||
'title',
|
||||
'author',
|
||||
'copyright_text',
|
||||
|
|
@ -32,20 +30,19 @@ protected function casts(): array
|
|||
return [
|
||||
'has_translation' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
'imported_from_ccli_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function groups(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongGroup::class);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -29,14 +29,9 @@ public function song(): BelongsTo
|
|||
return $this->belongsTo(Song::class);
|
||||
}
|
||||
|
||||
public function arrangementLabels(): HasMany
|
||||
public function arrangementGroups(): 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(SongArrangementGroup::class);
|
||||
}
|
||||
|
||||
public function serviceSongs(): HasMany
|
||||
|
|
|
|||
|
|
@ -6,15 +6,13 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SongArrangementSection extends Model
|
||||
class SongArrangementGroup extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'song_arrangement_labels';
|
||||
|
||||
protected $fillable = [
|
||||
'song_arrangement_id',
|
||||
'song_section_id',
|
||||
'song_group_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
|
|
@ -23,8 +21,8 @@ public function arrangement(): BelongsTo
|
|||
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
|
||||
}
|
||||
|
||||
public function section(): BelongsTo
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SongSection::class, 'song_section_id');
|
||||
return $this->belongsTo(SongGroup::class, 'song_group_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class SongArrangementLabel extends SongArrangementSection {}
|
||||
|
|
@ -7,35 +7,29 @@
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SongSection extends Model
|
||||
class SongGroup extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_id',
|
||||
'label_id',
|
||||
'name',
|
||||
'color',
|
||||
'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');
|
||||
return $this->hasMany(SongSlide::class);
|
||||
}
|
||||
|
||||
public function arrangementGroups(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementGroup::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,15 @@ class SongSlide extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_section_id',
|
||||
'song_group_id',
|
||||
'order',
|
||||
'text_content',
|
||||
'text_content_translated',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function section(): BelongsTo
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SongSection::class, 'song_section_id');
|
||||
return $this->belongsTo(SongGroup::class, 'song_group_id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\DuplicateCcliSongException;
|
||||
use App\Models\ApiRequestLog;
|
||||
use App\Models\Label;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Services\DTO\ParsedCcliSection;
|
||||
use App\Services\DTO\ParsedCcliSong;
|
||||
use App\Support\CcliLabels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
) {}
|
||||
|
||||
/** @return array{song: Song, status: 'created'|'restored', warnings: string[]} */
|
||||
public function import(string $rawText, ?string $sourceUrl = null): array
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$parsed = $this->parser->parse($rawText);
|
||||
|
||||
if ($parsed->ccliId === null || trim($parsed->ccliId) === '') {
|
||||
throw new RuntimeException('Keine CCLI-Nummer gefunden — bitte vollständige SongSelect-Liedseite einfügen.');
|
||||
}
|
||||
|
||||
$song = Song::withTrashed()->where('ccli_id', $parsed->ccliId)->first();
|
||||
$status = 'created';
|
||||
|
||||
if ($song !== null && ! $song->trashed() && $this->songHasContent($song)) {
|
||||
throw new DuplicateCcliSongException($song->id);
|
||||
}
|
||||
|
||||
if ($song !== null) {
|
||||
$status = 'restored';
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($parsed, $sourceUrl, $song, $status, $startedAt): array {
|
||||
if ($song !== null && $song->trashed()) {
|
||||
$song->restore();
|
||||
}
|
||||
|
||||
$song = $this->upsertSong($parsed, $sourceUrl, $song);
|
||||
$warnings = [];
|
||||
|
||||
$translationLanguage = Setting::get('default_translation_language', 'DE');
|
||||
if ($translationLanguage === null || trim($translationLanguage) === '') {
|
||||
$warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.';
|
||||
}
|
||||
|
||||
$sectionIds = [];
|
||||
$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;
|
||||
|
||||
$section->slides()->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;
|
||||
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
|
||||
|
||||
$section->slides()->create([
|
||||
'order' => $slideOrder + 1,
|
||||
'text_content' => implode("\n", $chunk),
|
||||
'text_content_translated' => $translatedLine,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$song->update([
|
||||
'has_translation' => $hasTranslation,
|
||||
'imported_from_ccli_at' => now(),
|
||||
'ccli_source_url' => $sourceUrl ?? $parsed->sourceUrl,
|
||||
]);
|
||||
|
||||
$arrangement = SongArrangement::updateOrCreate(
|
||||
['song_id' => $song->id, 'name' => 'normal'],
|
||||
['is_default' => true],
|
||||
);
|
||||
|
||||
SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete();
|
||||
|
||||
foreach ($sectionIds as $order => $sectionId) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $sectionId,
|
||||
'order' => $order + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'import',
|
||||
'endpoint' => 'paste',
|
||||
'status' => 'success',
|
||||
'request_context' => ['ccli_id' => $parsed->ccliId, 'mode' => $status],
|
||||
'response_summary' => "Song {$status}: {$song->title}",
|
||||
'response_body' => null,
|
||||
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||
]);
|
||||
|
||||
return ['song' => $song, 'status' => $status, 'warnings' => $warnings];
|
||||
});
|
||||
}
|
||||
|
||||
private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $song): Song
|
||||
{
|
||||
$songData = [
|
||||
'title' => $parsed->title,
|
||||
'author' => $parsed->author,
|
||||
'copyright_text' => $parsed->copyrightText,
|
||||
'copyright_year' => $parsed->year,
|
||||
'publisher' => $parsed->copyrightText,
|
||||
'ccli_source_url' => $sourceUrl ?? $parsed->sourceUrl,
|
||||
];
|
||||
|
||||
if ($song !== null) {
|
||||
$song->update($songData);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
||||
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()],
|
||||
);
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\DTO\ParsedCcliSection;
|
||||
use App\Services\DTO\ParsedCcliSong;
|
||||
use App\Support\CcliLabels;
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CcliPasteParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?Closure $sectionDetector = null,
|
||||
private readonly ?Closure $metadataDetector = null,
|
||||
) {}
|
||||
|
||||
public function parse(string $rawText): ParsedCcliSong
|
||||
{
|
||||
if (trim($rawText) === '') {
|
||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
||||
}
|
||||
|
||||
$lines = array_map(
|
||||
fn (string $line): string => trim($line),
|
||||
preg_split('/\r\n|\n|\r/', $rawText) ?: [],
|
||||
);
|
||||
|
||||
$isSectionLabel = $this->sectionDetector ?? fn (string $line): bool => CcliLabels::isSectionLabel($line);
|
||||
$isMetadataLine = $this->metadataDetector ?? fn (string $line): bool => CcliLabels::isMetadataLine($line);
|
||||
|
||||
$firstSectionIndex = null;
|
||||
foreach ($lines as $index => $line) {
|
||||
if ($line !== '' && $isSectionLabel($line)) {
|
||||
$firstSectionIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($firstSectionIndex === null) {
|
||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
||||
}
|
||||
|
||||
$headerLines = array_values(array_filter(
|
||||
array_slice($lines, 0, $firstSectionIndex),
|
||||
fn (string $line): bool => $line !== '',
|
||||
));
|
||||
|
||||
$title = $headerLines[0] ?? '';
|
||||
$author = $headerLines[1] ?? null;
|
||||
$ccliId = null;
|
||||
$year = null;
|
||||
$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;
|
||||
}
|
||||
|
||||
if (str_contains($line, '©')) {
|
||||
$copyrightText = $line;
|
||||
|
||||
if (preg_match('/©\s*(\d{4})/u', $line, $matches)) {
|
||||
$year = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isSectionLabel($line)) {
|
||||
if ($current !== null) {
|
||||
$sections[] = $current;
|
||||
}
|
||||
|
||||
$label = CcliLabels::parseLabel($line);
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = [
|
||||
'label' => $line,
|
||||
'kind' => CcliLabels::normalizeLabelName($label['kind']),
|
||||
'rawKind' => $label['kind'],
|
||||
'number' => $label['number'],
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
if ($current !== null) {
|
||||
$sections[] = $current;
|
||||
}
|
||||
|
||||
$parsedSections = $this->mergeTranslatedSections($sections);
|
||||
|
||||
if ($parsedSections === []) {
|
||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
||||
}
|
||||
|
||||
return new ParsedCcliSong(
|
||||
title: $title,
|
||||
author: $author,
|
||||
ccliId: $ccliId,
|
||||
year: $year,
|
||||
copyrightText: $copyrightText,
|
||||
sourceUrl: null,
|
||||
sections: $parsedSections,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{label: string, kind: string, rawKind: string, number: string|null, modifier: string|null, lines: string[]}> $sections
|
||||
* @return ParsedCcliSection[]
|
||||
*/
|
||||
private function mergeTranslatedSections(array $sections): array
|
||||
{
|
||||
$merged = [];
|
||||
$index = 0;
|
||||
|
||||
while ($index < count($sections)) {
|
||||
$section = $sections[$index];
|
||||
$next = $sections[$index + 1] ?? null;
|
||||
$linesTranslated = null;
|
||||
|
||||
if ($next !== null && $this->isTranslatedPair($section, $next)) {
|
||||
$linesTranslated = $next['lines'];
|
||||
$index++;
|
||||
}
|
||||
|
||||
$merged[] = new ParsedCcliSection(
|
||||
label: $section['label'],
|
||||
kind: $section['kind'],
|
||||
number: $section['number'],
|
||||
modifier: $section['modifier'],
|
||||
lines: $section['lines'],
|
||||
linesTranslated: $linesTranslated,
|
||||
);
|
||||
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{kind: string, rawKind: string, number: string|null} $section
|
||||
* @param array{kind: string, rawKind: string, number: string|null} $next
|
||||
*/
|
||||
private function isTranslatedPair(array $section, array $next): bool
|
||||
{
|
||||
return mb_strtolower($section['rawKind']) !== mb_strtolower($next['rawKind'])
|
||||
&& $this->canonicalLabel($section) === $this->canonicalLabel($next);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{kind: string, number: string|null} $section
|
||||
*/
|
||||
private function canonicalLabel(array $section): string
|
||||
{
|
||||
$label = trim($section['kind'].' '.($section['number'] ?? ''));
|
||||
|
||||
return mb_strtolower(CcliLabels::normalizeLabelName($label));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Services\DTO\ParsedCcliSection;
|
||||
use App\Support\CcliLabels;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class CcliTranslationPairingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Pair a CCLI paste as translation for an existing local song.
|
||||
* Returns mapping for UI review (does NOT save to DB).
|
||||
*
|
||||
* @return array{
|
||||
* song: Song,
|
||||
* mapping: array<int, array{local_label: string, ccli_label: string|null, distributed_lines: string[]}>,
|
||||
* unmatched_labels: string[],
|
||||
* distributed_text: string
|
||||
* }
|
||||
*/
|
||||
public function pair(Song $localSong, string $ccliRawText, string $arrangementName = 'normal'): array
|
||||
{
|
||||
$parsed = $this->parser->parse($ccliRawText);
|
||||
|
||||
$localSong->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
|
||||
$arrangement = $this->findArrangement($localSong, $arrangementName);
|
||||
|
||||
if ($arrangement === null) {
|
||||
return [
|
||||
'song' => $localSong,
|
||||
'mapping' => [],
|
||||
'unmatched_labels' => [],
|
||||
'distributed_text' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$ccliByCanonical = $this->sectionsByCanonicalLabel($parsed->sections);
|
||||
$mapping = [];
|
||||
$unmatchedLabels = [];
|
||||
$allDistributedLines = [];
|
||||
|
||||
foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
||||
if ($section === null || $label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localCanonical = $this->canonicalLabel($label->name, null);
|
||||
$matchedSection = $ccliByCanonical[$localCanonical] ?? null;
|
||||
$slides = $section->slides->sortBy('order')->values();
|
||||
|
||||
if ($matchedSection === null) {
|
||||
$unmatchedLabels[] = $label->name;
|
||||
$distributedLines = array_fill(0, max($slides->count(), 1), '');
|
||||
} else {
|
||||
$distributedLines = $this->distributeLines(
|
||||
$matchedSection->linesTranslated ?? $matchedSection->lines,
|
||||
$slides,
|
||||
);
|
||||
}
|
||||
|
||||
$allDistributedLines = array_merge($allDistributedLines, $distributedLines);
|
||||
$mapping[] = [
|
||||
'local_label' => $label->name,
|
||||
'ccli_label' => $matchedSection?->label,
|
||||
'distributed_lines' => $distributedLines,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'song' => $localSong,
|
||||
'mapping' => $mapping,
|
||||
'unmatched_labels' => $unmatchedLabels,
|
||||
'distributed_text' => implode("\n", $allDistributedLines),
|
||||
];
|
||||
}
|
||||
|
||||
private function findArrangement(Song $localSong, string $arrangementName): ?SongArrangement
|
||||
{
|
||||
return $localSong->arrangements->where('name', $arrangementName)->first()
|
||||
?? $localSong->arrangements->where('is_default', true)->first()
|
||||
?? $localSong->arrangements->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ParsedCcliSection[] $sections
|
||||
* @return array<string, ParsedCcliSection>
|
||||
*/
|
||||
private function sectionsByCanonicalLabel(array $sections): array
|
||||
{
|
||||
$byCanonical = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$canonical = $this->canonicalLabel($section->kind, $section->number);
|
||||
$byCanonical[$canonical] ??= $section;
|
||||
}
|
||||
|
||||
return $byCanonical;
|
||||
}
|
||||
|
||||
private function canonicalLabel(string $kind, ?string $number): string
|
||||
{
|
||||
$label = trim($kind.' '.($number ?? ''));
|
||||
|
||||
return mb_strtolower(CcliLabels::normalizeLabelName($label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute CCLI lines into local slide slots, preserving each local slide line count.
|
||||
*
|
||||
* @param string[] $lines
|
||||
* @param Collection<int, mixed> $slides
|
||||
* @return string[]
|
||||
*/
|
||||
private function distributeLines(array $lines, Collection $slides): array
|
||||
{
|
||||
if ($slides->isEmpty()) {
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$distributed = [];
|
||||
$offset = 0;
|
||||
$lastSlideIndex = $slides->count() - 1;
|
||||
|
||||
foreach ($slides as $index => $slide) {
|
||||
$lineCount = max(count(explode("\n", $slide->text_content ?? '')), 1);
|
||||
$chunk = array_slice($lines, $offset, $lineCount);
|
||||
$offset += $lineCount;
|
||||
|
||||
if ($index === $lastSlideIndex && $offset < count($lines)) {
|
||||
$chunk = array_merge($chunk, array_slice($lines, $offset));
|
||||
}
|
||||
|
||||
$distributed[] = implode("\n", $chunk);
|
||||
}
|
||||
|
||||
return $distributed;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ public function __construct(
|
|||
private readonly ?Closure $songFetcher = null,
|
||||
private readonly ?Closure $agendaFetcher = null,
|
||||
private readonly ?Closure $eventServiceFetcher = null,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function sync(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DTO;
|
||||
|
||||
final class LabelImportResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $newCount,
|
||||
public readonly int $updatedCount,
|
||||
public readonly int $totalInFile,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DTO;
|
||||
|
||||
final class MacroImportResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $new,
|
||||
public readonly int $updated,
|
||||
public readonly int $disabled,
|
||||
public readonly int $reEnabled,
|
||||
public readonly array $warnings,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DTO;
|
||||
|
||||
final readonly class ParsedCcliSection
|
||||
{
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public string $kind,
|
||||
public ?string $number,
|
||||
public ?string $modifier,
|
||||
/** @var string[] */
|
||||
public array $lines,
|
||||
/** @var string[]|null */
|
||||
public ?array $linesTranslated = null,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DTO;
|
||||
|
||||
final readonly class ParsedCcliSong
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public ?string $author,
|
||||
public ?string $ccliId,
|
||||
public ?string $year,
|
||||
public ?string $copyrightText,
|
||||
public ?string $sourceUrl,
|
||||
/** @var ParsedCcliSection[] */
|
||||
public array $sections,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DTO\LabelImportResult;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\LabelsFileReader;
|
||||
|
||||
class LabelsImportService
|
||||
{
|
||||
public function import(string $filePath, string $originalFilename): LabelImportResult
|
||||
{
|
||||
$library = LabelsFileReader::read($filePath);
|
||||
$newCount = 0;
|
||||
$updatedCount = 0;
|
||||
|
||||
DB::transaction(function () use ($library, &$newCount, &$updatedCount): void {
|
||||
foreach ($library->getLabels() as $parserLabel) {
|
||||
$name = $parserLabel->getName();
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$color = $parserLabel->getColorHex();
|
||||
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
|
||||
if ($existing === null) {
|
||||
Label::create([
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'last_imported_at' => now(),
|
||||
]);
|
||||
$newCount++;
|
||||
} else {
|
||||
$existing->update([
|
||||
'color' => $color,
|
||||
'last_imported_at' => now(),
|
||||
]);
|
||||
$updatedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Setting::set('labels_last_imported_at', now()->toIso8601String());
|
||||
Setting::set('labels_last_imported_filename', $originalFilename);
|
||||
|
||||
return new LabelImportResult($newCount, $updatedCount, count($library->getLabels()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceMacroAssignment;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class MacroResolutionService
|
||||
{
|
||||
/**
|
||||
* Returns active (non-hidden) assignments for a given service + part type.
|
||||
* Uses service-specific assignments if an override exists, otherwise global defaults.
|
||||
*/
|
||||
public function resolveAssignmentsForPart(Service $service, string $partType): Collection
|
||||
{
|
||||
$hasOverride = ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
->exists();
|
||||
|
||||
if ($hasOverride) {
|
||||
$rows = ServiceMacroAssignment::with(['macro', 'label'])
|
||||
->where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
->orderBy('order')
|
||||
->get();
|
||||
} else {
|
||||
$rows = MacroAssignment::with(['macro', 'label'])
|
||||
->where('part_type', $partType)
|
||||
->orderBy('order')
|
||||
->get();
|
||||
}
|
||||
|
||||
return $rows
|
||||
->reject(fn ($r) => $r->macro === null || $r->macro->isHidden())
|
||||
->reject(fn ($r) => $r->position === 'by_label' && ($r->label === null || $r->label->isHidden()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the macro export data for macros that apply to a specific slide.
|
||||
*
|
||||
* @param array $slideContext ['index' => int, 'total' => int, 'label_id' => int|null]
|
||||
* @return array<int, array{name: string, uuid: string, collectionName: string, collectionUuid: string}>
|
||||
*/
|
||||
public function macrosForSlide(Service $service, string $partType, array $slideContext): array
|
||||
{
|
||||
$assignments = $this->resolveAssignmentsForPart($service, $partType);
|
||||
|
||||
$matched = $assignments->filter(function ($a) use ($slideContext) {
|
||||
return match ($a->position) {
|
||||
'all_slides' => true,
|
||||
'first_slide' => $slideContext['index'] === 0,
|
||||
'last_slide' => $slideContext['index'] === $slideContext['total'] - 1,
|
||||
'by_label' => isset($slideContext['label_id'])
|
||||
&& (int) $a->label_id === (int) $slideContext['label_id'],
|
||||
default => false,
|
||||
};
|
||||
});
|
||||
|
||||
return $matched->map(fn ($a) => $this->toExportArray($a->macro))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of active assignments for a service + part (for UI badges).
|
||||
*/
|
||||
public function countAssignmentsForPart(Service $service, string $partType): int
|
||||
{
|
||||
return $this->resolveAssignmentsForPart($service, $partType)->count();
|
||||
}
|
||||
|
||||
private function toExportArray(Macro $macro): array
|
||||
{
|
||||
$collection = $macro->collections()->first();
|
||||
|
||||
return [
|
||||
'name' => $macro->name,
|
||||
'uuid' => $macro->uuid,
|
||||
'collectionName' => $collection?->name ?? '--MAIN--',
|
||||
'collectionUuid' => $collection?->uuid ?? '8D02FC57-83F8-4042-9B90-81C229728426',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\MacroCollection;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DTO\MacroImportResult;
|
||||
use App\Support\MacroColorConverter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\MacrosFileReader;
|
||||
|
||||
class MacrosImportService
|
||||
{
|
||||
public function import(string $filePath, string $originalFilename): MacroImportResult
|
||||
{
|
||||
$library = MacrosFileReader::read($filePath);
|
||||
$stats = ['new' => 0, 'updated' => 0, 'disabled' => 0, 'reEnabled' => 0];
|
||||
$importedUuids = [];
|
||||
|
||||
DB::transaction(function () use ($library, &$stats, &$importedUuids, $originalFilename): void {
|
||||
foreach ($library->getMacros() as $parserMacro) {
|
||||
$uuid = strtoupper($parserMacro->getUuid());
|
||||
if ($uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$importedUuids[] = $uuid;
|
||||
$color = MacroColorConverter::fromRgba($parserMacro->getColor());
|
||||
$data = [
|
||||
'uuid' => $uuid,
|
||||
'name' => $parserMacro->getName(),
|
||||
'color' => $color,
|
||||
'trigger_on_startup' => $parserMacro->getTriggerOnStartup(),
|
||||
'image_type' => $parserMacro->getImageType(),
|
||||
'action_count' => $parserMacro->getActionCount(),
|
||||
'last_imported_at' => now(),
|
||||
'last_imported_filename' => $originalFilename,
|
||||
'hidden_at' => null,
|
||||
];
|
||||
|
||||
$existing = Macro::where('uuid', $uuid)->first();
|
||||
if ($existing === null) {
|
||||
Macro::create($data);
|
||||
$stats['new']++;
|
||||
} else {
|
||||
$wasHidden = $existing->isHidden();
|
||||
$existing->update($data);
|
||||
if ($wasHidden) {
|
||||
$stats['reEnabled']++;
|
||||
} else {
|
||||
$stats['updated']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($importedUuids)) {
|
||||
$stats['disabled'] = Macro::whereNotIn('uuid', $importedUuids)
|
||||
->whereNull('hidden_at')
|
||||
->update(['hidden_at' => now()]);
|
||||
}
|
||||
|
||||
foreach ($library->getCollections() as $parserCollection) {
|
||||
$collUuid = strtoupper($parserCollection->getUuid());
|
||||
if ($collUuid === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$collection = MacroCollection::updateOrCreate(
|
||||
['uuid' => $collUuid],
|
||||
['name' => $parserCollection->getName(), 'last_imported_at' => now()],
|
||||
);
|
||||
|
||||
$collection->macros()->detach();
|
||||
foreach ($parserCollection->getMacroUuids() as $idx => $macroUuid) {
|
||||
$macro = Macro::where('uuid', strtoupper($macroUuid))->first();
|
||||
if ($macro) {
|
||||
$collection->macros()->attach($macro->id, ['order' => $idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Setting::set('macros_last_imported_at', now()->toIso8601String());
|
||||
Setting::set('macros_last_imported_filename', $originalFilename);
|
||||
|
||||
$warnings = $this->buildAssignmentWarnings();
|
||||
|
||||
return new MacroImportResult(
|
||||
$stats['new'],
|
||||
$stats['updated'],
|
||||
$stats['disabled'],
|
||||
$stats['reEnabled'],
|
||||
$warnings,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildAssignmentWarnings(): array
|
||||
{
|
||||
return MacroAssignment::whereHas('macro', fn ($q) => $q->whereNotNull('hidden_at'))
|
||||
->with('macro')
|
||||
->get()
|
||||
->map(fn ($a) => [
|
||||
'macro_name' => $a->macro->name,
|
||||
'macro_uuid' => $a->macro->uuid,
|
||||
'part_type' => $a->part_type,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -19,12 +19,7 @@ public function generatePlaylist(Service $service): array
|
|||
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
||||
->where('is_before_event', false)
|
||||
->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',
|
||||
])
|
||||
->with(['slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'serviceSong.song.groups.slides', 'serviceSong.arrangement.arrangementGroups.group'])
|
||||
->get();
|
||||
|
||||
if ($agendaItems->isEmpty()) {
|
||||
|
|
@ -54,7 +49,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$announcementPatterns = Setting::get('agenda_announcement_position');
|
||||
$announcementInserted = false;
|
||||
|
||||
$exportService = app(ProExportService::class);
|
||||
$exportService = new ProExportService;
|
||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
|
|
@ -62,20 +57,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 +69,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$tempDir,
|
||||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
$service,
|
||||
'information',
|
||||
);
|
||||
$announcementInserted = true;
|
||||
}
|
||||
|
|
@ -100,19 +80,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
if ($serviceSong->song_id && $serviceSong->song) {
|
||||
$song = $serviceSong->song;
|
||||
|
||||
if ($this->countSongLabels($song) === 0) {
|
||||
if ($song->groups()->count() === 0) {
|
||||
$skippedUnmatched++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$proPath = $exportService->generateProFile($song, $service);
|
||||
$proPath = $exportService->generateProFile($song);
|
||||
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||
$destPath = $tempDir.'/'.$proFilename;
|
||||
rename($proPath, $destPath);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
|
|
@ -127,11 +106,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 +114,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 +128,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$tempDir,
|
||||
$prependItems,
|
||||
$prependFiles,
|
||||
$service,
|
||||
'information',
|
||||
);
|
||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
||||
|
|
@ -203,18 +161,18 @@ 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.groups.slides');
|
||||
|
||||
$matchedSongs = $service->serviceSongs()
|
||||
->whereNotNull('song_id')
|
||||
->orderBy('order')
|
||||
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
|
||||
->with('song.groups.slides')
|
||||
->get();
|
||||
|
||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||
$skippedEmpty = 0;
|
||||
|
||||
$exportService = app(ProExportService::class);
|
||||
$exportService = new ProExportService;
|
||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
|
|
@ -233,19 +191,18 @@ private function generatePlaylistLegacy(Service $service): array
|
|||
foreach ($matchedSongs as $serviceSong) {
|
||||
$song = $serviceSong->song;
|
||||
|
||||
if (! $song || $this->countSongLabels($song) === 0) {
|
||||
if (! $song || $song->groups()->count() === 0) {
|
||||
$skippedEmpty++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$proPath = $exportService->generateProFile($song, $service);
|
||||
$proPath = $exportService->generateProFile($song);
|
||||
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||
$destPath = $tempDir.'/'.$proFilename;
|
||||
rename($proPath, $destPath);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||
$this->embedBackground($service, $embeddedFiles);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
|
|
@ -299,13 +256,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 +273,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,262 +358,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()
|
||||
->withCount('arrangementLabels')
|
||||
->get()
|
||||
->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;
|
||||
|
|
@ -17,11 +15,6 @@ class ProBundleExportService
|
|||
{
|
||||
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
|
||||
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
private readonly ServiceImageResolver $imageResolver,
|
||||
) {}
|
||||
|
||||
public function generateBundle(Service $service, string $blockType): string
|
||||
{
|
||||
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
|
||||
|
|
@ -35,16 +28,15 @@ public function generateBundle(Service $service, string $blockType): string
|
|||
|
||||
$groupName = ucfirst($blockType);
|
||||
|
||||
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
|
||||
return $this->buildBundleFromSlides($slides, $groupName);
|
||||
}
|
||||
|
||||
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||
{
|
||||
$agendaItem->loadMissing([
|
||||
'service',
|
||||
'slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSong.song.groups.slides',
|
||||
'serviceSong.song.arrangements.arrangementGroups.group',
|
||||
]);
|
||||
|
||||
$title = $agendaItem->title ?: 'Ablauf-Element';
|
||||
|
|
@ -52,22 +44,14 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
||||
$song = $agendaItem->serviceSong->song;
|
||||
|
||||
$labelCount = $song->arrangements()
|
||||
->withCount('arrangementLabels')
|
||||
->get()
|
||||
->sum('arrangement_labels_count');
|
||||
|
||||
if ($labelCount === 0) {
|
||||
if ($song->groups()->count() === 0) {
|
||||
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
|
||||
}
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
||||
$parserSong = (new ProExportService)->generateParserSong($song);
|
||||
$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 +63,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);
|
||||
}
|
||||
|
||||
/** @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): string
|
||||
{
|
||||
$slideData = [];
|
||||
$mediaFiles = [];
|
||||
$background = $this->backgroundData($service);
|
||||
$backgroundAttached = false;
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
||||
|
|
@ -115,37 +86,11 @@ private function buildBundleFromSlides(
|
|||
|
||||
$mediaFiles[$imageFilename] = $imageContent;
|
||||
|
||||
$singleSlideData = [
|
||||
$slideData[] = [
|
||||
'media' => $imageFilename,
|
||||
'format' => 'JPG',
|
||||
'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();
|
||||
$macros = $this->macroResolutionService->macrosForSlide(
|
||||
$service,
|
||||
$partType,
|
||||
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => null],
|
||||
);
|
||||
|
||||
if (! empty($macros)) {
|
||||
// ProPresenter parser currently supports one `macro` entry per slide
|
||||
$singleSlideData['macro'] = $macros[0];
|
||||
}
|
||||
}
|
||||
|
||||
$slideData[] = $singleSlideData;
|
||||
}
|
||||
|
||||
if ($backgroundAttached) {
|
||||
$this->embedBackground($service, $mediaFiles);
|
||||
}
|
||||
|
||||
$groups = [
|
||||
|
|
@ -173,83 +118,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';
|
||||
|
|
|
|||
|
|
@ -2,25 +2,20 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
|
||||
class ProExportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
private readonly ServiceImageResolver $imageResolver,
|
||||
) {}
|
||||
|
||||
public function generateProFile(Song $song, ?Service $service = null): string
|
||||
public function generateProFile(Song $song): string
|
||||
{
|
||||
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
|
||||
|
||||
ProFileGenerator::generateAndWrite(
|
||||
$tempPath,
|
||||
$song->title,
|
||||
$this->buildGroups($song, $service),
|
||||
$this->buildGroups($song),
|
||||
$this->buildArrangements($song),
|
||||
$this->buildCcliMetadata($song),
|
||||
);
|
||||
|
|
@ -28,79 +23,44 @@ public function generateProFile(Song $song, ?Service $service = null): string
|
|||
return $tempPath;
|
||||
}
|
||||
|
||||
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
|
||||
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
|
||||
{
|
||||
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
|
||||
|
||||
return ProFileGenerator::generate(
|
||||
$song->title,
|
||||
$this->buildGroups($song, $service),
|
||||
$this->buildGroups($song),
|
||||
$this->buildArrangements($song),
|
||||
$this->buildCcliMetadata($song),
|
||||
);
|
||||
}
|
||||
|
||||
private function buildGroups(Song $song, ?Service $service = null): array
|
||||
private function buildGroups(Song $song): array
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
|
||||
|
||||
$groups = [];
|
||||
$seenSectionIds = [];
|
||||
$background = $this->backgroundData($service);
|
||||
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
||||
if ($section === null || $label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($section->id, $seenSectionIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$seenSectionIds[] = $section->id;
|
||||
$macroData = $this->buildMacroData();
|
||||
|
||||
foreach ($song->groups->sortBy('order') as $group) {
|
||||
$slides = [];
|
||||
$sectionSlides = $section->slides->sortBy('order')->values();
|
||||
$totalSlides = $sectionSlides->count();
|
||||
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
|
||||
|
||||
foreach ($sectionSlides as $slideIndex => $slide) {
|
||||
foreach ($group->slides->sortBy('order') as $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,
|
||||
'song',
|
||||
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id],
|
||||
);
|
||||
|
||||
if (! empty($macros)) {
|
||||
// ProPresenter parser currently supports one `macro` entry per slide; keep the first resolved macro until stacked macros are supported.
|
||||
$slideData['macro'] = $macros[0];
|
||||
}
|
||||
if ($isCopyrightGroup && $macroData) {
|
||||
$slideData['macro'] = $macroData;
|
||||
}
|
||||
|
||||
$slides[] = $slideData;
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
'name' => $label->name,
|
||||
'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
|
||||
'name' => $group->name,
|
||||
'color' => ProImportService::hexToRgba($group->color),
|
||||
'slides' => $slides,
|
||||
];
|
||||
}
|
||||
|
|
@ -108,46 +68,32 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
return $groups;
|
||||
}
|
||||
|
||||
private function backgroundData(?Service $service): ?array
|
||||
private function buildMacroData(): ?array
|
||||
{
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
$name = Setting::get('macro_name');
|
||||
$uuid = Setting::get('macro_uuid');
|
||||
|
||||
$background = $this->imageResolver->backgroundFor($service);
|
||||
|
||||
if ($background === null) {
|
||||
if (! $name || ! $uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'bundleRelative' => true,
|
||||
'name' => $name,
|
||||
'uuid' => $uuid,
|
||||
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
|
||||
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
|
||||
];
|
||||
}
|
||||
|
||||
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 = [];
|
||||
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
|
||||
|
||||
foreach ($song->arrangements as $arrangement) {
|
||||
$arrangement->loadMissing('arrangementSections.section.label');
|
||||
|
||||
$groupNames = $arrangement->arrangementSections
|
||||
$groupNames = $arrangement->arrangementGroups
|
||||
->sortBy('order')
|
||||
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
||||
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use App\Support\MacroColorConverter;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\ProFileReader;
|
||||
|
|
@ -105,35 +103,28 @@ private function upsertSong(ProSong $proSong): Song
|
|||
}
|
||||
|
||||
$song->arrangements()->each(function (SongArrangement $arr) {
|
||||
$arr->arrangementSections()->delete();
|
||||
$arr->arrangementGroups()->delete();
|
||||
});
|
||||
$song->arrangements()->delete();
|
||||
$song->groups()->each(function (SongGroup $group) {
|
||||
$group->slides()->delete();
|
||||
});
|
||||
$song->groups()->delete();
|
||||
|
||||
$hasTranslation = false;
|
||||
$sectionsByName = [];
|
||||
$groupMap = [];
|
||||
|
||||
foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
|
||||
$groupName = $proGroup->getName();
|
||||
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
|
||||
foreach ($proSong->getGroups() as $position => $proGroup) {
|
||||
$color = $proGroup->getColor();
|
||||
$hexColor = $color ? self::rgbaToHex($color) : '#808080';
|
||||
|
||||
if ($existingLabel === null) {
|
||||
$color = $proGroup->getColor();
|
||||
$hexColor = MacroColorConverter::fromRgba($color);
|
||||
$songGroup = $song->groups()->create([
|
||||
'name' => $proGroup->getName(),
|
||||
'color' => $hexColor,
|
||||
'order' => $position,
|
||||
]);
|
||||
|
||||
$existingLabel = Label::create([
|
||||
'name' => $groupName,
|
||||
'color' => $hexColor,
|
||||
]);
|
||||
}
|
||||
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $existingLabel->id],
|
||||
['order' => $groupOrder + 1],
|
||||
);
|
||||
$section->update(['order' => $groupOrder + 1]);
|
||||
$sectionsByName[$groupName] = $section;
|
||||
|
||||
$section->slides()->delete();
|
||||
$groupMap[$proGroup->getName()] = $songGroup;
|
||||
|
||||
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
|
||||
$translatedText = null;
|
||||
|
|
@ -143,7 +134,7 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$hasTranslation = true;
|
||||
}
|
||||
|
||||
$section->slides()->create([
|
||||
$songGroup->slides()->create([
|
||||
'order' => $slidePosition,
|
||||
'text_content' => $proSlide->getPlainText(),
|
||||
'text_content_translated' => $translatedText,
|
||||
|
|
@ -162,19 +153,19 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
|
||||
|
||||
foreach ($groupsInArrangement as $order => $proGroup) {
|
||||
$section = $sectionsByName[$proGroup->getName()] ?? null;
|
||||
$songGroup = $groupMap[$proGroup->getName()] ?? null;
|
||||
|
||||
if ($section) {
|
||||
SongArrangementLabel::create([
|
||||
if ($songGroup) {
|
||||
SongArrangementGroup::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'song_group_id' => $songGroup->id,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,55 +2,36 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSection;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongService
|
||||
{
|
||||
/**
|
||||
* Sicherstellen, dass die Default-Labels und Song-Sektionen existieren.
|
||||
* Default-Gruppen für ein neues Lied erstellen.
|
||||
*
|
||||
* @return Collection<int, SongSection>
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
|
||||
*/
|
||||
public function createDefaultGroups(Song $song): Collection
|
||||
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$defaults = [
|
||||
['name' => 'Strophe 1', 'color' => '#3B82F6'],
|
||||
['name' => 'Refrain', 'color' => '#10B981'],
|
||||
['name' => 'Bridge', 'color' => '#F59E0B'],
|
||||
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
|
||||
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
|
||||
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
|
||||
];
|
||||
|
||||
$sections = collect();
|
||||
|
||||
foreach ($defaults as $index => $data) {
|
||||
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
|
||||
|
||||
if ($existing === null) {
|
||||
$existing = Label::create([
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
]);
|
||||
}
|
||||
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $existing->id],
|
||||
['order' => $index + 1],
|
||||
);
|
||||
$section->update(['order' => $index + 1]);
|
||||
|
||||
$sections->push($section);
|
||||
foreach ($defaults as $groupData) {
|
||||
$song->groups()->create($groupData);
|
||||
}
|
||||
|
||||
return $sections;
|
||||
return $song->groups()->orderBy('order')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
|
||||
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
|
||||
*/
|
||||
public function createDefaultArrangement(Song $song): SongArrangement
|
||||
{
|
||||
|
|
@ -59,16 +40,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
|
|||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$sections = $this->createDefaultGroups($song);
|
||||
$groups = $song->groups()->orderBy('order')->get();
|
||||
|
||||
foreach ($sections->values() as $index => $section) {
|
||||
$arrangement->arrangementSections()->create([
|
||||
'song_section_id' => $section->id,
|
||||
foreach ($groups as $index => $group) {
|
||||
$arrangement->arrangementGroups()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $arrangement->load('arrangementSections.section.label');
|
||||
return $arrangement->load('arrangementGroups');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,15 +63,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
|
|||
$clone->is_default = false;
|
||||
$clone->save();
|
||||
|
||||
foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
|
||||
SongArrangementLabel::create([
|
||||
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
|
||||
SongArrangementGroup::create([
|
||||
'song_arrangement_id' => $clone->id,
|
||||
'song_section_id' => $arrangementSection->song_section_id,
|
||||
'order' => $arrangementSection->order,
|
||||
'song_group_id' => $group->song_group_id,
|
||||
'order' => $group->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return $clone->load('arrangementSections.section.label');
|
||||
return $clone->load('arrangementGroups');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
|
||||
class TranslationService
|
||||
{
|
||||
/**
|
||||
* Text von einer URL abrufen (Best-Effort).
|
||||
*
|
||||
* HTML-Tags werden entfernt, nur reiner Text zurückgegeben.
|
||||
* Bei Fehlern wird null zurückgegeben, ohne Exception.
|
||||
*/
|
||||
public function fetchFromUrl(string $url): ?string
|
||||
{
|
||||
try {
|
||||
|
|
@ -27,30 +33,29 @@ public function fetchFromUrl(string $url): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
|
||||
*
|
||||
* Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
|
||||
* Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
|
||||
*
|
||||
* Beispiel:
|
||||
* Slide 1 hat 4 Zeilen → bekommt die nächsten 4 Zeilen der Übersetzung
|
||||
* Slide 2 hat 2 Zeilen → bekommt die nächsten 2 Zeilen
|
||||
* Slide 3 hat 4 Zeilen → bekommt die nächsten 4 Zeilen
|
||||
*/
|
||||
public function importTranslation(Song $song, string $text): void
|
||||
{
|
||||
$translatedLines = explode("\n", $text);
|
||||
$offset = 0;
|
||||
|
||||
$defaultArr = $song->arrangements()
|
||||
->where('is_default', true)
|
||||
->with(['arrangementSections' => fn ($q) => $q->orderBy('order'), 'arrangementSections.section.slides'])
|
||||
->first();
|
||||
// Alle Gruppen nach order sortiert laden, mit Slides
|
||||
$groups = $song->groups()->orderBy('order')->with([
|
||||
'slides' => fn ($query) => $query->orderBy('order'),
|
||||
])->get();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
$this->markAsTranslated($song);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
|
||||
if ($section === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($section->slides->sortBy('order') as $slide) {
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group->slides as $slide) {
|
||||
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
|
||||
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
||||
$offset += $originalLineCount;
|
||||
|
|
@ -64,23 +69,30 @@ public function importTranslation(Song $song, string $text): void
|
|||
$this->markAsTranslated($song);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song als "hat Übersetzung" markieren.
|
||||
*/
|
||||
public function markAsTranslated(Song $song): void
|
||||
{
|
||||
$song->update(['has_translation' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzung eines Songs komplett entfernen.
|
||||
*
|
||||
* Löscht alle text_content_translated Felder und setzt has_translation auf false.
|
||||
*/
|
||||
public function removeTranslation(Song $song): void
|
||||
{
|
||||
$sectionIds = $song->sections()
|
||||
->pluck('id')
|
||||
->unique()
|
||||
->values();
|
||||
// Alle Slides des Songs über die Gruppen aktualisieren
|
||||
$slideIds = SongSlide::whereIn(
|
||||
'song_group_id',
|
||||
$song->groups()->pluck('id')
|
||||
)->pluck('id');
|
||||
|
||||
if ($sectionIds->isNotEmpty()) {
|
||||
SongSlide::whereIn('song_section_id', $sectionIds)->update([
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
}
|
||||
SongSlide::whereIn('id', $slideIds)->update([
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
|
||||
$song->update(['has_translation' => false]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Regex matching CCLI footer/metadata lines (copyright, CCLI number).
|
||||
*/
|
||||
public const METADATA_PATTERN = '/©|CCLI[\s\-]|ccli\.com|SongSelect|All rights reserved|Alle Rechte vorbehalten/iu';
|
||||
|
||||
/**
|
||||
* Bidirectional English ↔ German label kind mapping.
|
||||
*/
|
||||
public const LABEL_NAME_MAP = [
|
||||
'Vers' => 'Verse',
|
||||
'Strophe' => 'Verse',
|
||||
'Refrain' => 'Chorus',
|
||||
'Brücke' => 'Bridge',
|
||||
'Vorrefrain' => 'Pre-Chorus',
|
||||
'Schluss' => 'Ending',
|
||||
'Zwischenspiel' => 'Interlude',
|
||||
];
|
||||
|
||||
public static function isSectionLabel(string $line): bool
|
||||
{
|
||||
return (bool) preg_match(self::SECTION_LABEL_PATTERN, trim($line));
|
||||
}
|
||||
|
||||
public static function isMetadataLine(string $line): bool
|
||||
{
|
||||
return (bool) preg_match(self::METADATA_PATTERN, $line);
|
||||
}
|
||||
|
||||
public static function extractCcliId(string $line): ?string
|
||||
{
|
||||
if (preg_match('/CCLI\s*(?:License|Lizenz)/iu', $line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/CCLI(?:[\s-]*(?:Song|Lied(?:nummer)?|Nr\.?))?[\s#:\-.]*(\d+)/iu', $line, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
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)) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
$kind = $matches['kind'];
|
||||
$suffix = $matches['suffix'] ?? '';
|
||||
|
||||
return (self::LABEL_NAME_MAP[$kind] ?? $kind).$suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{kind: string, number: string|null, modifier: string|null}|null
|
||||
*/
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modifier = $matches['modifier'] ?? null;
|
||||
|
||||
return [
|
||||
'kind' => $matches['kind'],
|
||||
'number' => $matches['number'] ?? null,
|
||||
'modifier' => $modifier !== null ? rtrim($modifier, '.') : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class MacroColorConverter
|
||||
{
|
||||
public static function fromRgba(?array $rgba): ?string
|
||||
{
|
||||
if ($rgba === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'#%02X%02X%02X',
|
||||
(int) round(max(0.0, min(1.0, $rgba['r'])) * 255),
|
||||
(int) round(max(0.0, min(1.0, $rgba['g'])) * 255),
|
||||
(int) round(max(0.0, min(1.0, $rgba['b'])) * 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -52,10 +52,6 @@ RUN composer run-script post-autoload-dump --no-interaction || true
|
|||
|
||||
RUN npm run build
|
||||
|
||||
# Copy built Vite assets to /app/public-build so they survive the bind-mount at runtime.
|
||||
# At boot, boot-container.sh copies from /app/public-build/ into the bind-mounted /app/public/.
|
||||
RUN cp -r /app/public /app/public-build
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Production
|
||||
|
|
@ -78,6 +74,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
unzip \
|
||||
zip \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js 20 LTS — needed at boot to build Vite assets into the bind-mounted public/
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# LibreOffice for PowerPoint → PDF conversion (large layer, separate cache)
|
||||
|
|
@ -131,7 +133,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|||
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q "pong" || exit 1
|
||||
|
||||
# boot-container.sh runs as root: creates dirs, sets permissions,
|
||||
# creates DB on first run, syncs pre-built Vite assets from /app/public-build/,
|
||||
# runs migrations, warms caches, then exec's supervisord (CMD).
|
||||
# creates DB on first run, builds Vite assets, runs migrations,
|
||||
# warms caches, then exec's supervisord (CMD).
|
||||
ENTRYPOINT ["/app/build/boot-container.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ chmod -R 775 storage bootstrap/cache database 2>/dev/null || true
|
|||
|
||||
rm -f /app/public/hot
|
||||
|
||||
echo "[boot] Syncing pre-built Vite assets to bind-mounted public/ ..."
|
||||
cp -r /app/public-build/* /app/public/ 2>/dev/null || true
|
||||
echo "[boot] Building Vite assets..."
|
||||
npm run build
|
||||
|
||||
# Create RELATIVE storage symlink (public/storage → ../storage/app/public).
|
||||
# Must be relative: Caddy serves the bind-mounted ./public from the host, where
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ fi
|
|||
echo "[init] First run detected — initializing application..."
|
||||
|
||||
touch "$DB_PATH"
|
||||
chown www-data:www-data "$DB_PATH"
|
||||
chmod 664 "$DB_PATH"
|
||||
|
||||
if [ -z "${APP_KEY}" ]; then
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"php": "^8.2",
|
||||
"5pm-hdh/churchtools-api": "^2.1",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
|
|
|
|||
12
composer.lock
generated
12
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "87837501106e784aa10ddd7743056cba",
|
||||
"content-hash": "424677667864ca1fffd6f4af9632aa92",
|
||||
"packages": [
|
||||
{
|
||||
"name": "5pm-hdh/churchtools-api",
|
||||
|
|
@ -3819,7 +3819,7 @@
|
|||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git",
|
||||
"reference": "9e3e719806d8db3941444b8424fdd56b3b534aa8"
|
||||
"reference": "22ba4aff7d29683297c0397e1bbc3699dc35ac03"
|
||||
},
|
||||
"require": {
|
||||
"google/protobuf": "^4.0",
|
||||
|
|
@ -3838,7 +3838,7 @@
|
|||
}
|
||||
},
|
||||
"description": "ProPresenter song file parser",
|
||||
"time": "2026-05-03T19:40:09+00:00"
|
||||
"time": "2026-03-30T11:26:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
|
|
@ -10867,8 +10867,8 @@
|
|||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.4"
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
'SANCTUM_STATEFUL_DOMAINS',
|
||||
implode(',', array_filter([
|
||||
$appHost,
|
||||
'pp-planer.ddev.site',
|
||||
'localhost',
|
||||
'localhost:8000',
|
||||
'127.0.0.1',
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Label;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class LabelFactory extends Factory
|
||||
{
|
||||
protected $model = Label::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->words(2, true),
|
||||
'color' => sprintf('#%06X', mt_rand(0, 0xFFFFFF)),
|
||||
'hidden_at' => null,
|
||||
'last_imported_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Macro;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MacroFactory extends Factory
|
||||
{
|
||||
protected $model = Macro::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => strtoupper(Str::uuid()->toString()),
|
||||
'name' => $this->faker->words(3, true),
|
||||
'color' => sprintf('#%06X', mt_rand(0, 0xFFFFFF)),
|
||||
'trigger_on_startup' => false,
|
||||
'image_type' => 0,
|
||||
'action_count' => 0,
|
||||
'hidden_at' => null,
|
||||
'last_imported_at' => null,
|
||||
'last_imported_filename' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue