Compare commits

..

24 commits

Author SHA1 Message Date
Thorsten Bus a10068e783 add sisyphus notepad changes 2026-05-04 07:41:39 +02:00
Thorsten Bus eee35722fb fix(export): inject macros for information/moderation/sermon/agenda_item parts
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-04 06:48:17 +02:00
Thorsten Bus 84adf2b6fb fix: add hidden-label warning badge, use null color fallback in ProImportService 2026-05-04 06:41:05 +02:00
Thorsten Bus 41d4bfe2b7 fix: rename song_group_id to label_id in Vue, add data-testid to MacroIcon, fix Pint style 2026-05-04 06:33:04 +02:00
Thorsten Bus 6d83b5f38c feat(service-edit): macro icon + Anpassen/Standard flow on service edit page 2026-05-04 00:37:05 +02:00
Thorsten Bus 444e6704c5 feat(components): add MacroIcon badge component with count and warning indicator 2026-05-04 00:30:28 +02:00
Thorsten Bus c714f30647 refactor(vue): update ArrangementConfigurator and ArrangementDialog to use label_id 2026-05-04 00:28:49 +02:00
Thorsten Bus b88ae3e918 feat(settings): SettingsController passes assignments, macros, labels, collections to Settings page 2026-05-04 00:26:56 +02:00
Thorsten Bus f494a8a0d7 feat(settings): add LabelImport, MacroImport, MacroAssignments, MacroPicker, LabelPicker components 2026-05-04 00:25:57 +02:00
Thorsten Bus c1cb9bf820 feat(settings): restructure Settings.vue into sidebar layout with 4 submenus + AgendaSettings.vue 2026-05-03 23:50:46 +02:00
Thorsten Bus 6ce5b6e018 feat(controllers): add macro/label import + global assignment + service override controllers and routes
- MacroImportController + LabelImportController: POST endpoints accepting uploaded .bin files,
  delegating to MacrosImportService / LabelsImportService and returning import stats / warnings as JSON.
  Generic German 422 error if parser rejects the file.
- MacroAssignmentController: index renders Settings Inertia page with assignments / macros / labels /
  collections / last-import metadata. store/update/destroy/reorder for global MacroAssignment rows.
- ServiceMacroOverrideController: store snapshots all matching global MacroAssignments into
  service-specific rows when a service opts to override; destroy removes both override and
  service-specific assignments. storeAssignment / updateAssignment / destroyAssignment manage the
  per-service rows directly.
- routes/web.php: 12 new named routes inside the auth middleware group; reorder route placed before
  {macroAssignment} parameter route to avoid capture conflict.
- Tests: 19 new Pest tests across 4 feature files (54 assertions). Full suite 376 passed.
2026-05-03 23:17:04 +02:00
Thorsten Bus cef247336e feat(export): use MacroResolutionService in ProExportService for flexible macro injection 2026-05-03 23:08:22 +02:00
Thorsten Bus 81b2a9caf6 feat(services): add LabelsImportService, MacrosImportService, MacroResolutionService 2026-05-03 23:03:32 +02:00
Thorsten Bus bdbf0c65e3 refactor(php): rename SongGroup references throughout controllers/services/tests
Replace all SongGroup/SongArrangementGroup model usages with Label/SongArrangementLabel
after the schema migration to global labels. Updates 12 app files and 11 test files:

- SongService: createDefaultGroups now finds-or-creates global Labels by name
- ArrangementController: validates label_id (labels are global, no song-ownership)
- ProImportService: imports groups as Labels (firstOrCreate by name); does not
  overwrite existing label colors per spec
- ProExportService/SongPdfController/TranslationService/etc: traverse via
  arrangements -> arrangementLabels -> label -> songSlides chain
- All test factories and assertions adapted to label-based schema
2026-05-03 22:55:02 +02:00
Thorsten Bus a1612dc3ef feat(support): add MacroColorConverter utility 2026-05-03 22:31:44 +02:00
Thorsten Bus 846bd12f90 feat(models): add Label/Macro/MacroAssignment/ServiceMacro models and remove SongGroup 2026-05-03 22:27:21 +02:00
Thorsten Bus 2a02f65517 test: update DatabaseSchemaTest and WipeLegacySongDataTest for new schema 2026-05-03 22:21:49 +02:00
Thorsten Bus bf153b2906 feat(db): auto-migrate 4 legacy macro settings to new assignment system 2026-05-03 22:20:07 +02:00
Thorsten Bus 2b27aa50d5 feat(db)!: drop song_groups, introduce label_id on song_slides, add song_arrangement_labels (BREAKING) 2026-05-03 22:20:01 +02:00
Thorsten Bus a65bf3d595 feat(db): add macro_assignments, service macro override tables, and guarded legacy data wipe 2026-05-03 22:16:46 +02:00
Thorsten Bus 09ab4821fc feat(db): create macros, macro_collections, and junction tables 2026-05-03 22:13:28 +02:00
Thorsten Bus 860db0405f docs: record labels migration verification
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-03 22:10:46 +02:00
Thorsten Bus 767e22eac8 feat(db): create labels table for global slide labels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-03 22:10:35 +02:00
Thorsten Bus e489a984eb chore(deps): bump PHP to 8.4 and update propresenter/parser with Macro/Label support
- Raise PHP requirement from ^8.2 to ^8.4 (parser requires 8.4)
- New parser classes available: MacrosFileReader, LabelsFileReader,
  Macro, MacroLibrary, MacroCollection, Label, LabelLibrary
- Add programmatic test fixtures for macros-sample.bin + labels-sample.bin
- Fix ServiceAgendaItemFactory sort_order to auto-increment
2026-05-03 22:07:56 +02:00
101 changed files with 4260 additions and 917 deletions

View file

@ -0,0 +1,9 @@
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

View file

@ -0,0 +1,33 @@
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

View file

@ -14,6 +14,7 @@
- 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
@ -350,3 +351,5 @@ ### 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.

View file

@ -0,0 +1,41 @@
# 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

View file

@ -0,0 +1,145 @@
# 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.

View file

@ -2,12 +2,12 @@
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArrangementController extends Controller
{
@ -23,17 +23,24 @@ public function store(Request $request, Song $song): RedirectResponse
'is_default' => false,
]);
$groups = $song->groups()->orderBy('order')->get();
$rows = $groups->map(fn ($group, $index) => [
$defaultArr = $song->arrangements()->where('is_default', true)->first();
if ($defaultArr === null) {
return;
}
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $al->label_id,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
])->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
$arrangement->arrangementLabels()->insert($rows);
}
});
@ -47,14 +54,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
]);
DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementGroups');
$arrangement->loadMissing('arrangementLabels');
$clone = $arrangement->song->arrangements()->create([
'name' => $data['name'],
'is_default' => false,
]);
$this->cloneGroups($arrangement, $clone);
$this->cloneArrangementLabels($arrangement, $clone);
});
return back()->with('success', 'Arrangement wurde geklont.');
@ -64,33 +71,22 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
{
$data = $request->validate([
'groups' => ['array'],
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'],
'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
$uniqueGroupIds = $groupIds->unique()->values();
$labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
$validGroupIds = $arrangement->song->groups()
->whereIn('id', $uniqueGroupIds)
->pluck('id');
DB::transaction(function () use ($arrangement, $labelIds, $data): void {
$arrangement->arrangementLabels()->delete();
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
$rows = $labelIds
->values()
->map(fn (int $songGroupId, int $index) => [
->map(fn (int $labelId, int $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroupId,
'label_id' => $labelId,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
@ -98,14 +94,12 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
$arrangement->arrangementLabels()->insert($rows);
}
if (! empty($data['group_colors'])) {
foreach ($data['group_colors'] as $groupId => $color) {
$arrangement->song->groups()
->whereKey((int) $groupId)
->update(['color' => $color]);
foreach ($data['group_colors'] as $labelId => $color) {
Label::whereKey((int) $labelId)->update(['color' => $color]);
}
}
});
@ -136,28 +130,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
return back()->with('success', 'Arrangement wurde gelöscht.');
}
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void
{
if ($source === null) {
return;
}
$groups = $source->arrangementGroups
$arrangementLabels = $source->arrangementLabels
->sortBy('order')
->values();
$rows = $groups
->map(fn ($arrangementGroup) => [
$rows = $arrangementLabels
->map(fn ($arrangementLabel) => [
'song_arrangement_id' => $target->id,
'song_group_id' => $arrangementGroup->song_group_id,
'order' => $arrangementGroup->order,
'label_id' => $arrangementLabel->label_id,
'order' => $arrangementLabel->order,
'created_at' => now(),
'updated_at' => now(),
])
->all();
if ($rows !== []) {
$target->arrangementGroups()->insert($rows);
$target->arrangementLabels()->insert($rows);
}
}
}

View file

@ -0,0 +1,37 @@
<?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,
]);
}
}

View file

@ -0,0 +1,86 @@
<?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]);
}
}

View file

@ -0,0 +1,41 @@
<?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,
]);
}
}

View file

@ -49,15 +49,23 @@ public function importPro(Request $request): JsonResponse
public function downloadPro(Song $song): BinaryFileResponse
{
if ($song->groups()->count() === 0) {
if ($this->countSongLabels($song) === 0) {
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
}
$exportService = new ProExportService;
$exportService = app(ProExportService::class);
$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');
}
}

View file

@ -4,10 +4,12 @@
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;
@ -129,15 +131,13 @@ public function edit(Service $service): Response
$service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song',
'serviceSongs.song.groups',
'serviceSongs.song.arrangements.arrangementGroups.group',
'serviceSongs.song.arrangements.arrangementLabels.label',
'serviceSongs.arrangement',
'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides',
'agendaItems.serviceSong.song.groups.slides',
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'agendaItems.serviceSong.arrangement.arrangementLabels.label',
]);
$songsCatalog = Song::query()
@ -227,6 +227,34 @@ public function edit(Service $service): Response
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(),
];
}
return Inertia::render('Services/Edit', [
'service' => [
'id' => $service->id,
@ -253,15 +281,7 @@ public function edit(Service $service): Response
'title' => $ss->song->title,
'ccli_id' => $ss->song->ccli_id,
'has_translation' => $ss->song->has_translation,
'groups' => $ss->song->groups
->sortBy('order')
->values()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
]),
'groups' => $this->collectSongLabels($ss->song),
'arrangements' => $ss->song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->values()
@ -269,13 +289,13 @@ public function edit(Service $service): Response
'id' => $arrangement->id,
'name' => $arrangement->name,
'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementGroups
'groups' => $arrangement->arrangementLabels
->sortBy('order')
->values()
->map(fn ($arrangementGroup) => [
'id' => $arrangementGroup->group?->id,
'name' => $arrangementGroup->group?->name,
'color' => $arrangementGroup->group?->color,
->map(fn ($arrangementLabel) => [
'id' => $arrangementLabel->label?->id,
'name' => $arrangementLabel->label?->name,
'color' => $arrangementLabel->label?->color,
])
->filter(fn ($group) => $group['id'] !== null)
->values(),
@ -302,6 +322,7 @@ public function edit(Service $service): Response
'title' => $nextService->title,
'date' => $nextService->date?->toDateString(),
] : null,
'macros_per_part' => $macros_per_part,
]);
}
@ -412,4 +433,25 @@ 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->arrangementLabels
->sortBy('order')
->values()
->map(fn ($arrangementLabel) => [
'id' => $arrangementLabel->label?->id,
'name' => $arrangementLabel->label?->name,
'color' => $arrangementLabel->label?->color,
'order' => $arrangementLabel->order,
])
->filter(fn ($group) => $group['id'] !== null)
->values();
}
}

View file

@ -0,0 +1,94 @@
<?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]);
}
}

View file

@ -2,6 +2,10 @@
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;
@ -10,11 +14,7 @@
class SettingsController extends Controller
{
private const MACRO_KEYS = [
'macro_name',
'macro_uuid',
'macro_collection_name',
'macro_collection_uuid',
private const AGENDA_KEYS = [
'agenda_start_title',
'agenda_end_title',
'agenda_announcement_position',
@ -24,19 +24,31 @@ class SettingsController extends Controller
public function index(): Response
{
$settings = [];
foreach (self::MACRO_KEYS as $key) {
foreach (self::AGENDA_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', 'in:'.implode(',', self::MACRO_KEYS)],
'key' => ['required', 'string', 'in:'.implode(',', self::AGENDA_KEYS)],
'value' => ['nullable', 'string', 'max:500'],
]);

View file

@ -15,9 +15,6 @@ public function __construct(
private readonly SongService $songService,
) {}
/**
* Alle Songs auflisten (paginiert, durchsuchbar).
*/
public function index(Request $request): JsonResponse
{
$query = Song::query();
@ -53,15 +50,11 @@ 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;
@ -69,16 +62,13 @@ public function store(SongRequest $request): JsonResponse
return response()->json([
'message' => 'Song erfolgreich erstellt',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
], 201);
}
/**
* Song mit Gruppen, Slides und Arrangements anzeigen.
*/
public function show(int $id): JsonResponse
{
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
$song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
@ -89,9 +79,6 @@ public function show(int $id): JsonResponse
]);
}
/**
* Song-Metadaten aktualisieren.
*/
public function update(SongRequest $request, int $id): JsonResponse
{
$song = Song::find($id);
@ -104,13 +91,10 @@ public function update(SongRequest $request, int $id): JsonResponse
return response()->json([
'message' => 'Song erfolgreich aktualisiert',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
]);
}
/**
* Song soft-löschen.
*/
public function destroy(int $id): JsonResponse
{
$song = Song::find($id);
@ -126,11 +110,35 @@ public function destroy(int $id): JsonResponse
]);
}
/**
* Song-Detail formatieren.
*/
private function formatSongDetail(Song $song): array
{
$defaultArr = $song->arrangements->firstWhere('is_default', true);
$groupsPayload = [];
if ($defaultArr !== null) {
$groupsPayload = $defaultArr->arrangementLabels
->sortBy('order')
->values()
->map(fn ($al) => [
'id' => $al->label?->id,
'name' => $al->label?->name,
'color' => $al->label?->color,
'order' => $al->order,
'slides' => $al->label
? $al->label->songSlides
->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,
@ -144,27 +152,15 @@ private 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' => $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(),
'groups' => $groupsPayload,
'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id,
'name' => $arr->name,
'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
'id' => $ag->id,
'song_group_id' => $ag->song_group_id,
'order' => $ag->order,
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
'id' => $al->id,
'label_id' => $al->label_id,
'order' => $al->order,
])->toArray(),
])->toArray(),
];

View file

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

View file

@ -18,41 +18,48 @@ public function __construct(
public function page(Song $song): Response
{
$song->load([
'groups' => fn ($query) => $query
->orderBy('order')
->with([
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
]),
'arrangements' => fn ($q) => $q->where('is_default', true),
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'),
'arrangements.arrangementLabels.label.songSlides',
]);
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
$groups = collect();
if ($defaultArr !== null) {
$groups = $defaultArr->arrangementLabels
->sortBy('order')
->values()
->map(fn ($al) => [
'id' => $al->label?->id,
'name' => $al->label?->name,
'color' => $al->label?->color,
'order' => $al->order,
'slides' => $al->label
? $al->label->songSlides
->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' => $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(),
'groups' => $groups,
],
]);
}
/**
* 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([
@ -72,11 +79,6 @@ 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);
@ -98,9 +100,6 @@ public function import(int $songId, Request $request): JsonResponse
]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*/
public function destroy(int $songId): JsonResponse
{
$song = Song::find($songId);

42
app/Models/Label.php Normal file
View file

@ -0,0 +1,42 @@
<?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 songSlides(): HasMany
{
return $this->hasMany(SongSlide::class);
}
public function macroAssignments(): HasMany
{
return $this->hasMany(MacroAssignment::class);
}
public function isHidden(): bool
{
return $this->hidden_at !== null;
}
}

51
app/Models/Macro.php Normal file
View file

@ -0,0 +1,51 @@
<?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;
}
}

View file

@ -0,0 +1,27 @@
<?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);
}
}

View file

@ -0,0 +1,29 @@
<?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');
}
}

View file

@ -0,0 +1,33 @@
<?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);
}
}

View file

@ -0,0 +1,26 @@
<?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);
}
}

View file

@ -33,11 +33,6 @@ protected function casts(): array
];
}
public function groups(): HasMany
{
return $this->hasMany(SongGroup::class);
}
public function arrangements(): HasMany
{
return $this->hasMany(SongArrangement::class);

View file

@ -29,9 +29,9 @@ public function song(): BelongsTo
return $this->belongsTo(Song::class);
}
public function arrangementGroups(): HasMany
public function arrangementLabels(): HasMany
{
return $this->hasMany(SongArrangementGroup::class);
return $this->hasMany(SongArrangementLabel::class)->orderBy('order');
}
public function serviceSongs(): HasMany

View file

@ -6,13 +6,13 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SongArrangementGroup extends Model
class SongArrangementLabel extends Model
{
use HasFactory;
protected $fillable = [
'song_arrangement_id',
'song_group_id',
'label_id',
'order',
];
@ -21,8 +21,8 @@ public function arrangement(): BelongsTo
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
}
public function group(): BelongsTo
public function label(): BelongsTo
{
return $this->belongsTo(SongGroup::class, 'song_group_id');
return $this->belongsTo(Label::class);
}
}

View file

@ -1,35 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SongGroup extends Model
{
use HasFactory;
protected $fillable = [
'song_id',
'name',
'color',
'order',
];
public function song(): BelongsTo
{
return $this->belongsTo(Song::class);
}
public function slides(): HasMany
{
return $this->hasMany(SongSlide::class);
}
public function arrangementGroups(): HasMany
{
return $this->hasMany(SongArrangementGroup::class);
}
}

View file

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

View file

@ -28,8 +28,7 @@ public function __construct(
private readonly ?Closure $songFetcher = null,
private readonly ?Closure $agendaFetcher = null,
private readonly ?Closure $eventServiceFetcher = null,
) {
}
) {}
public function sync(): array
{

View file

@ -0,0 +1,12 @@
<?php
namespace App\Services\DTO;
final class LabelImportResult
{
public function __construct(
public readonly int $newCount,
public readonly int $updatedCount,
public readonly int $totalInFile,
) {}
}

View file

@ -0,0 +1,14 @@
<?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,
) {}
}

View file

@ -0,0 +1,49 @@
<?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()));
}
}

View file

@ -0,0 +1,85 @@
<?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',
];
}
}

View file

@ -0,0 +1,110 @@
<?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();
}
}

View file

@ -19,7 +19,11 @@ 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.groups.slides', 'serviceSong.arrangement.arrangementGroups.group'])
->with([
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'serviceSong.arrangement.arrangementLabels.label',
])
->get();
if ($agendaItems->isEmpty()) {
@ -49,7 +53,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$announcementPatterns = Setting::get('agenda_announcement_position');
$announcementInserted = false;
$exportService = new ProExportService;
$exportService = app(ProExportService::class);
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
mkdir($tempDir, 0755, true);
@ -80,13 +84,13 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if ($serviceSong->song_id && $serviceSong->song) {
$song = $serviceSong->song;
if ($song->groups()->count() === 0) {
if ($this->countSongLabels($song) === 0) {
$skippedUnmatched++;
continue;
}
$proPath = $exportService->generateProFile($song);
$proPath = $exportService->generateProFile($song, $service);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath);
@ -161,18 +165,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
*/
private function generatePlaylistLegacy(Service $service): array
{
$service->loadMissing('serviceSongs.song.groups.slides');
$service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
$matchedSongs = $service->serviceSongs()
->whereNotNull('song_id')
->orderBy('order')
->with('song.groups.slides')
->with('song.arrangements.arrangementLabels.label.songSlides')
->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
$skippedEmpty = 0;
$exportService = new ProExportService;
$exportService = app(ProExportService::class);
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
mkdir($tempDir, 0755, true);
@ -191,13 +195,13 @@ private function generatePlaylistLegacy(Service $service): array
foreach ($matchedSongs as $serviceSong) {
$song = $serviceSong->song;
if (! $song || $song->groups()->count() === 0) {
if (! $song || $this->countSongLabels($song) === 0) {
$skippedEmpty++;
continue;
}
$proPath = $exportService->generateProFile($song);
$proPath = $exportService->generateProFile($song, $service);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath);
@ -366,6 +370,14 @@ protected function writeProFile(string $path, string $name, array $groups, array
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
}
private function countSongLabels(\App\Models\Song $song): int
{
return $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -15,6 +15,10 @@ class ProBundleExportService
{
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
) {}
public function generateBundle(Service $service, string $blockType): string
{
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
@ -28,15 +32,15 @@ public function generateBundle(Service $service, string $blockType): string
$groupName = ucfirst($blockType);
return $this->buildBundleFromSlides($slides, $groupName);
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType);
}
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
{
$agendaItem->loadMissing([
'service',
'slides',
'serviceSong.song.groups.slides',
'serviceSong.song.arrangements.arrangementGroups.group',
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
]);
$title = $agendaItem->title ?: 'Ablauf-Element';
@ -44,11 +48,16 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
$song = $agendaItem->serviceSong->song;
if ($song->groups()->count() === 0) {
$labelCount = $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
if ($labelCount === 0) {
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
}
$parserSong = (new ProExportService)->generateParserSong($song);
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
$proFilename = self::safeFilename($song->title).'.pro';
$bundle = new PresentationBundle($parserSong, $proFilename);
@ -63,11 +72,11 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
->orderBy('sort_order')
->get();
return $this->buildBundleFromSlides($slides, $title);
return $this->buildBundleFromSlides($slides, $title, $agendaItem->service, 'agenda_item');
}
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
private function buildBundleFromSlides($slides, string $groupName): string
private function buildBundleFromSlides($slides, string $groupName, ?Service $service = null, ?string $partType = null): string
{
$slideData = [];
$mediaFiles = [];
@ -86,11 +95,28 @@ private function buildBundleFromSlides($slides, string $groupName): string
$mediaFiles[$imageFilename] = $imageContent;
$slideData[] = [
$singleSlideData = [
'media' => $imageFilename,
'format' => 'JPG',
'label' => $slide->original_filename,
];
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;
}
$groups = [

View file

@ -2,20 +2,24 @@
namespace App\Services;
use App\Models\Setting;
use App\Models\Service;
use App\Models\Song;
use ProPresenter\Parser\ProFileGenerator;
class ProExportService
{
public function generateProFile(Song $song): string
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
) {}
public function generateProFile(Song $song, ?Service $service = null): string
{
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
ProFileGenerator::generateAndWrite(
$tempPath,
$song->title,
$this->buildGroups($song),
$this->buildGroups($song, $service),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
@ -23,44 +27,73 @@ public function generateProFile(Song $song): string
return $tempPath;
}
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
{
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
$song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
return ProFileGenerator::generate(
$song->title,
$this->buildGroups($song),
$this->buildGroups($song, $service),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
}
private function buildGroups(Song $song): array
private function buildGroups(Song $song, ?Service $service = null): array
{
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
if ($defaultArr === null) {
return [];
}
$defaultArr->loadMissing('arrangementLabels.label.songSlides');
$groups = [];
$macroData = $this->buildMacroData();
$seenLabelIds = [];
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
continue;
}
if (in_array($label->id, $seenLabelIds, true)) {
continue;
}
$seenLabelIds[] = $label->id;
foreach ($song->groups->sortBy('order') as $group) {
$slides = [];
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
$labelSlides = $label->songSlides->sortBy('order')->values();
$totalSlides = $labelSlides->count();
foreach ($group->slides->sortBy('order') as $slide) {
foreach ($labelSlides as $slideIndex => $slide) {
$slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) {
$slideData['translation'] = $slide->text_content_translated;
}
if ($isCopyrightGroup && $macroData) {
$slideData['macro'] = $macroData;
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];
}
}
$slides[] = $slideData;
}
$groups[] = [
'name' => $group->name,
'color' => ProImportService::hexToRgba($group->color),
'name' => $label->name,
'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
'slides' => $slides,
];
}
@ -68,32 +101,16 @@ private function buildGroups(Song $song): array
return $groups;
}
private function buildMacroData(): ?array
{
$name = Setting::get('macro_name');
$uuid = Setting::get('macro_uuid');
if (! $name || ! $uuid) {
return null;
}
return [
'name' => $name,
'uuid' => $uuid,
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
];
}
private function buildArrangements(Song $song): array
{
$arrangements = [];
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
foreach ($song->arrangements as $arrangement) {
$groupNames = $arrangement->arrangementGroups
$arrangement->loadMissing('arrangementLabels.label');
$groupNames = $arrangement->arrangementLabels
->sortBy('order')
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null)
->map(fn ($al) => $al->label?->name)
->filter()
->values()
->toArray();

View file

@ -2,10 +2,11 @@
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Support\MacroColorConverter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\ProFileReader;
@ -103,28 +104,30 @@ private function upsertSong(ProSong $proSong): Song
}
$song->arrangements()->each(function (SongArrangement $arr) {
$arr->arrangementGroups()->delete();
$arr->arrangementLabels()->delete();
});
$song->arrangements()->delete();
$song->groups()->each(function (SongGroup $group) {
$group->slides()->delete();
});
$song->groups()->delete();
$hasTranslation = false;
$groupMap = [];
$labelsByName = [];
foreach ($proSong->getGroups() as $position => $proGroup) {
$color = $proGroup->getColor();
$hexColor = $color ? self::rgbaToHex($color) : '#808080';
foreach ($proSong->getGroups() as $proGroup) {
$groupName = $proGroup->getName();
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
$songGroup = $song->groups()->create([
'name' => $proGroup->getName(),
'color' => $hexColor,
'order' => $position,
]);
if ($existingLabel === null) {
$color = $proGroup->getColor();
$hexColor = MacroColorConverter::fromRgba($color);
$groupMap[$proGroup->getName()] = $songGroup;
$existingLabel = Label::create([
'name' => $groupName,
'color' => $hexColor,
]);
}
$labelsByName[$groupName] = $existingLabel;
$existingLabel->songSlides()->delete();
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
$translatedText = null;
@ -134,7 +137,7 @@ private function upsertSong(ProSong $proSong): Song
$hasTranslation = true;
}
$songGroup->slides()->create([
$existingLabel->songSlides()->create([
'order' => $slidePosition,
'text_content' => $proSlide->getPlainText(),
'text_content_translated' => $translatedText,
@ -153,19 +156,19 @@ private function upsertSong(ProSong $proSong): Song
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
foreach ($groupsInArrangement as $order => $proGroup) {
$songGroup = $groupMap[$proGroup->getName()] ?? null;
$label = $labelsByName[$proGroup->getName()] ?? null;
if ($songGroup) {
SongArrangementGroup::create([
if ($label) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroup->id,
'label_id' => $label->id,
'order' => $order,
]);
}
}
}
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']);
return $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
}
public static function rgbaToHex(array $rgba): string

View file

@ -2,36 +2,48 @@
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class SongService
{
/**
* Default-Gruppen für ein neues Lied erstellen.
* Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren.
*
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
* @return Collection<int, Label>
*/
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
public function createDefaultGroups(Song $song): Collection
{
$defaults = [
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
['name' => 'Strophe 1', 'color' => '#3B82F6'],
['name' => 'Refrain', 'color' => '#10B981'],
['name' => 'Bridge', 'color' => '#F59E0B'],
];
foreach ($defaults as $groupData) {
$song->groups()->create($groupData);
$labels = collect();
foreach ($defaults as $data) {
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
if ($existing === null) {
$existing = Label::create([
'name' => $data['name'],
'color' => $data['color'],
]);
}
$labels->push($existing);
}
return $song->groups()->orderBy('order')->get();
return $labels;
}
/**
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
*/
public function createDefaultArrangement(Song $song): SongArrangement
{
@ -40,16 +52,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
'is_default' => true,
]);
$groups = $song->groups()->orderBy('order')->get();
$labels = $this->createDefaultGroups($song);
foreach ($groups as $index => $group) {
$arrangement->arrangementGroups()->create([
'song_group_id' => $group->id,
foreach ($labels->values() as $index => $label) {
$arrangement->arrangementLabels()->create([
'label_id' => $label->id,
'order' => $index + 1,
]);
}
return $arrangement->load('arrangementGroups');
return $arrangement->load('arrangementLabels.label');
}
/**
@ -63,15 +75,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
$clone->is_default = false;
$clone->save();
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
SongArrangementGroup::create([
foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) {
SongArrangementLabel::create([
'song_arrangement_id' => $clone->id,
'song_group_id' => $group->song_group_id,
'order' => $group->order,
'label_id' => $arrangementLabel->label_id,
'order' => $arrangementLabel->order,
]);
}
return $clone->load('arrangementGroups');
return $clone->load('arrangementLabels.label');
});
}
}

View file

@ -8,12 +8,6 @@
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 {
@ -33,29 +27,30 @@ 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;
// Alle Gruppen nach order sortiert laden, mit Slides
$groups = $song->groups()->orderBy('order')->with([
'slides' => fn ($query) => $query->orderBy('order'),
])->get();
$defaultArr = $song->arrangements()
->where('is_default', true)
->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides'])
->first();
foreach ($groups as $group) {
foreach ($group->slides as $slide) {
if ($defaultArr === null) {
$this->markAsTranslated($song);
return;
}
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
continue;
}
foreach ($label->songSlides->sortBy('order') as $slide) {
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
$offset += $originalLineCount;
@ -69,30 +64,25 @@ 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
{
// Alle Slides des Songs über die Gruppen aktualisieren
$slideIds = SongSlide::whereIn(
'song_group_id',
$song->groups()->pluck('id')
)->pluck('id');
$labelIds = $song->arrangements()
->with('arrangementLabels')
->get()
->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
->unique()
->values();
SongSlide::whereIn('id', $slideIds)->update([
'text_content_translated' => null,
]);
if ($labelIds->isNotEmpty()) {
SongSlide::whereIn('label_id', $labelIds)->update([
'text_content_translated' => null,
]);
}
$song->update(['has_translation' => false]);
}

View file

@ -0,0 +1,20 @@
<?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),
);
}
}

View file

@ -15,7 +15,7 @@
}
],
"require": {
"php": "^8.2",
"php": "^8.4",
"5pm-hdh/churchtools-api": "^2.1",
"barryvdh/laravel-dompdf": "^3.1",
"inertiajs/inertia-laravel": "^2.0",

12
composer.lock generated
View file

@ -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": "424677667864ca1fffd6f4af9632aa92",
"content-hash": "87837501106e784aa10ddd7743056cba",
"packages": [
{
"name": "5pm-hdh/churchtools-api",
@ -3819,7 +3819,7 @@
"source": {
"type": "git",
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git",
"reference": "22ba4aff7d29683297c0397e1bbc3699dc35ac03"
"reference": "9e3e719806d8db3941444b8424fdd56b3b534aa8"
},
"require": {
"google/protobuf": "^4.0",
@ -3838,7 +3838,7 @@
}
},
"description": "ProPresenter song file parser",
"time": "2026-03-30T11:26:29+00:00"
"time": "2026-05-03T19:40:09+00:00"
},
{
"name": "psr/clock",
@ -10867,8 +10867,8 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
"php": "^8.4"
},
"platform-dev": [],
"plugin-api-version": "2.2.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View file

@ -0,0 +1,21 @@
<?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,
];
}
}

View file

@ -0,0 +1,27 @@
<?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,
];
}
}

View file

@ -9,6 +9,8 @@
class ServiceAgendaItemFactory extends Factory
{
private static int $nextSortOrder = 1;
protected $model = ServiceAgendaItem::class;
public function definition(): array
@ -28,7 +30,7 @@ public function definition(): array
$this->faker->numberBetween(1, 2)
),
'service_song_id' => null,
'sort_order' => $this->faker->numberBetween(1, 20),
'sort_order' => self::$nextSortOrder++,
];
}

View file

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

View file

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

View file

@ -1,22 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Song;
use App\Models\SongGroup;
use Illuminate\Database\Eloquent\Factories\Factory;
class SongGroupFactory extends Factory
{
protected $model = SongGroup::class;
public function definition(): array
{
return [
'song_id' => Song::factory(),
'name' => $this->faker->randomElement(['Verse 1', 'Verse 2', 'Chorus', 'Bridge']),
'color' => $this->faker->hexColor(),
'order' => $this->faker->numberBetween(1, 10),
];
}
}

View file

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

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('labels', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('color', 7)->nullable();
$table->timestamp('hidden_at')->nullable();
$table->timestamp('last_imported_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('labels');
}
};

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('macros', function (Blueprint $table) {
$table->id();
$table->string('uuid', 36)->unique();
$table->string('name');
$table->string('color', 7)->nullable();
$table->boolean('trigger_on_startup')->default(false);
$table->unsignedSmallInteger('image_type')->default(0);
$table->unsignedInteger('action_count')->default(0);
$table->timestamp('hidden_at')->nullable();
$table->timestamp('last_imported_at')->nullable();
$table->string('last_imported_filename')->nullable();
$table->timestamps();
$table->index('hidden_at');
});
Schema::create('macro_collections', function (Blueprint $table) {
$table->id();
$table->string('uuid', 36)->unique();
$table->string('name');
$table->timestamp('last_imported_at')->nullable();
$table->timestamps();
});
Schema::create('macro_collection_macros', function (Blueprint $table) {
$table->id();
$table->foreignId('macro_collection_id')->constrained()->cascadeOnDelete();
$table->foreignId('macro_id')->constrained('macros')->cascadeOnDelete();
$table->unsignedInteger('order')->default(0);
$table->timestamps();
$table->unique(['macro_collection_id', 'macro_id']);
});
}
public function down(): void
{
Schema::dropIfExists('macro_collection_macros');
Schema::dropIfExists('macro_collections');
Schema::dropIfExists('macros');
}
};

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('macro_assignments', function (Blueprint $table) {
$table->id();
$table->enum('part_type', ['information', 'moderation', 'sermon', 'song', 'agenda_item']);
$table->foreignId('macro_id')->constrained('macros')->restrictOnDelete();
$table->enum('position', ['all_slides', 'first_slide', 'last_slide', 'by_label']);
$table->foreignId('label_id')->nullable()->constrained('labels')->restrictOnDelete();
$table->unsignedInteger('order')->default(0);
$table->timestamps();
$table->index(['part_type', 'order']);
});
}
public function down(): void
{
Schema::dropIfExists('macro_assignments');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('service_macro_overrides', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
$table->enum('part_type', ['information', 'moderation', 'sermon', 'song', 'agenda_item']);
$table->timestamps();
$table->unique(['service_id', 'part_type']);
});
Schema::create('service_macro_assignments', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
$table->enum('part_type', ['information', 'moderation', 'sermon', 'song', 'agenda_item']);
$table->foreignId('macro_id')->constrained('macros')->restrictOnDelete();
$table->enum('position', ['all_slides', 'first_slide', 'last_slide', 'by_label']);
$table->foreignId('label_id')->nullable()->constrained('labels')->restrictOnDelete();
$table->unsignedInteger('order')->default(0);
$table->timestamps();
$table->index(['service_id', 'part_type', 'order']);
});
}
public function down(): void
{
Schema::dropIfExists('service_macro_assignments');
Schema::dropIfExists('service_macro_overrides');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('song_groups')) {
return;
}
if (DB::table('song_groups')->count() === 0) {
return;
}
DB::statement('PRAGMA foreign_keys = OFF');
try {
DB::table('song_arrangement_groups')->delete();
DB::table('song_arrangements')->delete();
DB::table('song_slides')->delete();
DB::table('song_groups')->delete();
DB::table('service_songs')->delete();
DB::table('songs')->delete();
} finally {
DB::statement('PRAGMA foreign_keys = ON');
}
}
public function down(): void
{
// Data is unrecoverable — intentional no-op
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('song_slides', function (Blueprint $table) {
$table->dropUnique(['song_group_id', 'order']);
$table->dropIndex(['song_group_id']);
$table->dropForeign(['song_group_id']);
$table->dropColumn('song_group_id');
});
Schema::table('song_slides', function (Blueprint $table) {
$table->foreignId('label_id')->nullable()->constrained('labels')->restrictOnDelete();
});
Schema::dropIfExists('song_arrangement_groups');
Schema::dropIfExists('song_groups');
Schema::create('song_arrangement_labels', function (Blueprint $table) {
$table->id();
$table->foreignId('song_arrangement_id')->constrained()->cascadeOnDelete();
$table->foreignId('label_id')->constrained('labels')->restrictOnDelete();
$table->unsignedInteger('order');
$table->timestamps();
$table->index(['song_arrangement_id', 'order']);
});
}
public function down(): void
{
throw new \RuntimeException('Destructive migration: rollback not supported. Restore from backup.');
}
};

View file

@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$name = DB::table('settings')->where('key', 'macro_name')->value('value');
$uuid = DB::table('settings')->where('key', 'macro_uuid')->value('value');
if (empty($name) || empty($uuid)) {
return;
}
$collectionName = DB::table('settings')->where('key', 'macro_collection_name')->value('value');
$collectionUuid = DB::table('settings')->where('key', 'macro_collection_uuid')->value('value');
DB::transaction(function () use ($name, $uuid, $collectionName, $collectionUuid) {
$labelId = DB::table('labels')->insertGetId([
'name' => 'Copyright',
'color' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$macroId = DB::table('macros')->insertGetId([
'uuid' => strtoupper($uuid),
'name' => $name,
'last_imported_filename' => 'legacy-settings-migration',
'last_imported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
if (! empty($collectionUuid) && ! empty($collectionName)) {
$collectionId = DB::table('macro_collections')->insertGetId([
'uuid' => strtoupper($collectionUuid),
'name' => $collectionName,
'last_imported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('macro_collection_macros')->insert([
'macro_collection_id' => $collectionId,
'macro_id' => $macroId,
'order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('macro_assignments')->insert([
'part_type' => 'song',
'macro_id' => $macroId,
'position' => 'by_label',
'label_id' => $labelId,
'order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settings')
->whereIn('key', ['macro_name', 'macro_uuid', 'macro_collection_name', 'macro_collection_uuid'])
->delete();
});
}
public function down(): void {}
};

View file

@ -67,7 +67,16 @@ watch(
watch(
selectedArrangement,
(arrangement) => {
arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
if (arrangement?.groups) {
arrangementGroups.value = arrangement.groups.map((group) => ({ ...group }))
} else if (arrangement?.arrangement_groups) {
arrangementGroups.value = arrangement.arrangement_groups
.map((ag) => props.availableGroups.find((g) => g.id === ag.label_id))
.filter(Boolean)
.map((g) => ({ ...g }))
} else {
arrangementGroups.value = []
}
},
{ immediate: true },
)
@ -157,7 +166,7 @@ function saveArrangement() {
`/arrangements/${selectedArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
label_id: group.id,
order: index + 1,
})),
},

View file

@ -170,8 +170,11 @@ watch(
return
}
const arr = localArrangements.value.find((a) => a.id === Number(id))
if (arr?.groups?.length) {
arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
const arrGroups = arr?.groups ?? (arr?.arrangement_groups
? arr.arrangement_groups.map((ag) => props.availableGroups.find((g) => g.id === ag.label_id)).filter(Boolean)
: [])
if (arrGroups.length) {
arrangementGroups.value = arrGroups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
} else {
// Fallback: show all available groups in order (Master)
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, slides: g.slides ?? [], _uid: `${g.id}-master-${i}-${Date.now()}` }))
@ -323,7 +326,7 @@ function saveArrangement() {
`/arrangements/${currentArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
label_id: group.id,
order: index + 1,
})),
},

View file

@ -0,0 +1,90 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
labels: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
const model = defineModel({ type: Number, default: null })
const search = ref('')
const isOpen = ref(false)
const filtered = computed(() =>
props.labels.filter((l) => l.name.toLowerCase().includes(search.value.toLowerCase())),
)
const selected = computed(() => props.labels.find((l) => l.id === model.value))
function select(label) {
model.value = label.id
search.value = ''
isOpen.value = false
}
function open() {
if (!props.disabled) isOpen.value = true
}
function close() {
setTimeout(() => {
isOpen.value = false
}, 150)
}
</script>
<template>
<div class="relative">
<div
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="open"
data-testid="label-picker-trigger"
>
<span
v-if="selected?.color"
class="h-4 w-4 shrink-0 rounded"
:style="{ backgroundColor: selected.color }"
/>
<span class="flex-1 truncate text-gray-700">
{{ selected ? selected.name : 'Label auswählen...' }}
</span>
</div>
<div
v-if="isOpen"
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg"
data-testid="label-picker-dropdown"
>
<div class="border-b border-gray-100 p-2">
<input
v-model="search"
type="text"
placeholder="Label suchen..."
class="w-full rounded border-gray-300 text-sm"
autofocus
@blur="close"
/>
</div>
<div class="max-h-48 overflow-y-auto">
<button
v-for="label in filtered"
:key="label.id"
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-amber-50"
:class="label.hidden_at ? 'text-gray-400' : 'text-gray-700'"
:data-testid="'label-option-' + label.id"
@click="select(label)"
>
<span
class="h-3 w-3 shrink-0 rounded"
:style="label.color ? { backgroundColor: label.color } : { backgroundColor: '#ccc' }"
/>
<span class="truncate">{{ label.name }}{{ label.hidden_at ? ' (deaktiviert)' : '' }}</span>
</button>
<div v-if="filtered.length === 0" class="px-3 py-4 text-center text-sm text-gray-400">
Kein Label gefunden
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,28 @@
<script setup>
defineProps({
count: { type: Number, default: 0 },
hasWarning: { type: Boolean, default: false },
})
defineEmits(['click'])
</script>
<template>
<button
v-if="count > 0"
data-testid="macro-icon"
class="relative flex items-center justify-center rounded-lg bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200"
:aria-label="`${count} Makro-Zuweisungen`"
@click="$emit('click')"
>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="ml-1">{{ count }}</span>
<span
v-if="hasWarning"
class="absolute -right-1 -top-1 flex h-3 w-3 items-center justify-center rounded-full bg-red-500"
aria-label="Warnung: deaktiviertes Makro"
/>
</button>
</template>

View file

@ -0,0 +1,116 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
macros: { type: Array, default: () => [] },
collections: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
const model = defineModel({ type: Number, default: null })
const search = ref('')
const isOpen = ref(false)
const filteredMacros = computed(() => {
const q = search.value.toLowerCase()
return props.macros.filter((m) => m.name.toLowerCase().includes(q))
})
const groupedMacros = computed(() => {
const groups = {}
props.collections.forEach((c) => {
groups[c.name] = []
})
groups['Ohne Sammlung'] = []
filteredMacros.value.forEach((m) => {
const coll = props.collections.find((c) => c.macros?.some((cm) => cm.id === m.id))
const key = coll?.name ?? 'Ohne Sammlung'
if (!groups[key]) groups[key] = []
groups[key].push(m)
})
return groups
})
const selectedMacro = computed(() => props.macros.find((m) => m.id === model.value))
function select(macro) {
model.value = macro.id
search.value = ''
isOpen.value = false
}
function open() {
if (!props.disabled) isOpen.value = true
}
function close() {
setTimeout(() => {
isOpen.value = false
}, 150)
}
</script>
<template>
<div class="relative">
<div
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="open"
data-testid="macro-picker-trigger"
>
<span
v-if="selectedMacro?.color"
class="h-4 w-4 shrink-0 rounded"
:style="{ backgroundColor: selectedMacro.color }"
/>
<span class="flex-1 truncate text-gray-700">
{{ selectedMacro ? selectedMacro.name : 'Makro auswählen...' }}
</span>
</div>
<div
v-if="isOpen"
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg"
data-testid="macro-picker-dropdown"
>
<div class="border-b border-gray-100 p-2">
<input
v-model="search"
type="text"
placeholder="Makro suchen..."
class="w-full rounded border-gray-300 text-sm"
data-testid="macro-picker-search"
autofocus
@blur="close"
/>
</div>
<div class="max-h-64 overflow-y-auto">
<template v-for="(group, name) in groupedMacros" :key="name">
<div
v-if="group.length > 0"
class="bg-gray-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
>
{{ name }}
</div>
<button
v-for="macro in group"
:key="macro.id"
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-amber-50"
:class="macro.hidden_at ? 'text-gray-400' : 'text-gray-700'"
:data-testid="'macro-option-' + macro.id"
@click="select(macro)"
>
<span
class="h-3 w-3 shrink-0 rounded"
:style="macro.color ? { backgroundColor: macro.color } : { backgroundColor: '#ccc' }"
/>
<span class="truncate">{{ macro.name }}{{ macro.hidden_at ? ' (deaktiviert)' : '' }}</span>
</button>
</template>
<div v-if="filteredMacros.length === 0" class="px-3 py-4 text-center text-sm text-gray-400">
Kein Makro gefunden
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps({
serviceId: { type: Number, required: true },
partType: { type: String, required: true },
partLabel: { type: String, required: true },
isOverridden: { type: Boolean, default: false },
assignments: { type: Array, default: () => [] },
hasWarning: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const busy = ref(false)
const positionLabels = {
all_slides: 'Alle Folien',
first_slide: 'Erste Folie',
last_slide: 'Letzte Folie',
by_label: 'Nach Label',
}
function csrfToken() {
return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '')
}
async function anpassen() {
busy.value = true
try {
await fetch(route('services.macro-overrides.store', props.serviceId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({ part_type: props.partType }),
})
router.reload({ preserveScroll: true })
emit('close')
} finally {
busy.value = false
}
}
async function revertToGlobal() {
if (!confirm('Soll die Anpassung aufgehoben werden? Die globalen Zuweisungen werden wiederhergestellt.')) return
busy.value = true
try {
await fetch(route('services.macro-overrides.destroy', props.serviceId), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({ part_type: props.partType }),
})
router.reload({ preserveScroll: true })
emit('close')
} finally {
busy.value = false
}
}
</script>
<template>
<div
class="absolute right-0 top-8 z-50 w-80 rounded-xl border border-gray-200 bg-white shadow-lg"
:data-testid="'macro-panel-' + partType"
>
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h4 class="text-sm font-semibold text-gray-900">Makros für {{ partLabel }}</h4>
<button
class="text-gray-400 hover:text-gray-600"
:data-testid="'btn-close-macro-panel-' + partType"
@click="emit('close')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-4">
<!-- Override status badge -->
<div
v-if="isOverridden"
class="mb-3 flex items-center gap-1.5 rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Anpassung aktiv für diesen Gottesdienst
</div>
<div v-else class="mb-3 text-xs text-gray-400">
Globale Zuweisungen werden verwendet
</div>
<!-- Assignments list -->
<div v-if="assignments.length > 0" class="mb-3 space-y-1">
<div
v-for="a in assignments"
:key="a.id"
class="flex items-center gap-2 rounded-lg bg-gray-50 px-2 py-1.5 text-xs"
:data-testid="'macro-panel-assignment-' + a.id"
>
<span
v-if="a.macro_color"
class="h-3 w-3 shrink-0 rounded"
:style="{ backgroundColor: a.macro_color }"
/>
<span class="flex-1 truncate text-gray-700">{{ a.macro_name }}</span>
<span class="text-gray-400">{{ positionLabels[a.position] }}</span>
<span v-if="a.macro_hidden" class="rounded bg-amber-100 px-1 py-0.5 text-amber-700"></span>
</div>
</div>
<p v-else class="mb-3 text-xs text-gray-400">Keine Makros zugewiesen.</p>
<!-- Action buttons -->
<div class="flex gap-2">
<button
v-if="!isOverridden"
class="flex-1 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600 disabled:opacity-50"
:disabled="busy"
title="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-testid="'btn-anpassen-' + partType"
@click="anpassen"
>
Anpassen
</button>
<button
v-else
class="flex-1 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
:disabled="busy"
:data-testid="'btn-standard-' + partType"
@click="revertToGlobal"
>
Auf Standard zurücksetzen
</button>
</div>
</div>
</div>
</template>

View file

@ -150,10 +150,10 @@ const arrangements = computed(() => {
name: arr.name,
is_default: arr.is_default,
groups: arr.arrangement_groups.map((ag) => {
const group = songData.value.groups.find((g) => g.id === ag.song_group_id)
const group = songData.value.groups.find((g) => g.id === ag.label_id)
return {
id: ag.song_group_id,
id: ag.label_id,
name: group?.name ?? 'Unbekannt',
color: group?.color ?? '#6b7280',
order: ag.order,

View file

@ -6,6 +6,8 @@ import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
import AgendaItemRow from '@/Components/AgendaItemRow.vue'
import SongAgendaItem from '@/Components/SongAgendaItem.vue'
import ArrangementDialog from '@/Components/ArrangementDialog.vue'
import MacroIcon from '@/Components/MacroIcon.vue'
import ServicePartMacroPanel from '@/Components/ServicePartMacroPanel.vue'
const props = defineProps({
service: {
@ -40,8 +42,30 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
macros_per_part: {
type: Object,
default: () => ({}),
},
})
const openMacroPanel = ref(null)
const macroPartLabels = {
information: 'Informationen',
moderation: 'Moderation',
sermon: 'Predigt',
song: 'Lieder',
agenda_item: 'Ablaufpunkte',
}
function toggleMacroPanel(partType) {
openMacroPanel.value = openMacroPanel.value === partType ? null : partType
}
function macroPartData(partType) {
return props.macros_per_part?.[partType] ?? { count: 0, is_overridden: false, has_warning: false, assignments: [] }
}
const formattedDate = computed(() => {
if (!props.service.date) return ''
return new Date(props.service.date).toLocaleDateString('de-DE', {
@ -92,9 +116,9 @@ function getArrangements(item) {
name: arr.name,
is_default: arr.is_default,
groups: (arr.arrangement_groups ?? []).map((ag) => {
const group = song.groups?.find((g) => g.id === ag.song_group_id) ?? ag.group ?? {}
const group = song.groups?.find((g) => g.id === ag.label_id) ?? ag.group ?? {}
return {
id: ag.song_group_id ?? group.id,
id: ag.label_id ?? group.id,
name: group.name ?? 'Unbekannt',
color: group.color ?? '#6b7280',
order: ag.order,
@ -358,7 +382,31 @@ async function downloadService() {
<!-- Ablauf (Agenda) -->
<div class="py-6">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3>
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-gray-900">Ablauf</h3>
<div class="flex items-center gap-1.5">
<template v-for="partType in ['agenda_item', 'moderation', 'sermon', 'song']" :key="partType">
<div class="relative">
<MacroIcon
:count="macroPartData(partType).count"
:has-warning="macroPartData(partType).has_warning"
:data-testid="'macro-icon-' + partType"
@click="toggleMacroPanel(partType)"
/>
<ServicePartMacroPanel
v-if="openMacroPanel === partType"
:service-id="service.id"
:part-type="partType"
:part-label="macroPartLabels[partType]"
:is-overridden="macroPartData(partType).is_overridden"
:assignments="macroPartData(partType).assignments"
:has-warning="macroPartData(partType).has_warning"
@close="openMacroPanel = null"
/>
</div>
</template>
</div>
</div>
<!-- Empty state -->
<div v-if="!agendaItems || agendaItems.length === 0"
@ -426,10 +474,28 @@ async function downloadService() {
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div>
<div class="flex-1">
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
<p class="text-xs text-gray-500">Info-Folien für alle kommenden Services</p>
</div>
<div class="relative">
<MacroIcon
:count="macroPartData('information').count"
:has-warning="macroPartData('information').has_warning"
:data-testid="'macro-icon-information'"
@click="toggleMacroPanel('information')"
/>
<ServicePartMacroPanel
v-if="openMacroPanel === 'information'"
:service-id="service.id"
:part-type="'information'"
:part-label="macroPartLabels.information"
:is-overridden="macroPartData('information').is_overridden"
:assignments="macroPartData('information').assignments"
:has-warning="macroPartData('information').has_warning"
@close="openMacroPanel = null"
/>
</div>
</div>
<InformationBlock
:service-id="service.id"

View file

@ -1,70 +1,41 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import AgendaSettings from './Settings/AgendaSettings.vue'
import LabelImport from './Settings/LabelImport.vue'
import MacroAssignments from './Settings/MacroAssignments.vue'
import MacroImport from './Settings/MacroImport.vue'
import { Head } from '@inertiajs/vue3'
import { ref, reactive } from 'vue'
import { onMounted, ref } from 'vue'
const props = defineProps({
settings: {
type: Object,
default: () => ({}),
},
settings: { type: Object, default: () => ({}) },
assignments: { type: Array, default: () => [] },
macros: { type: Array, default: () => [] },
labels: { type: Array, default: () => [] },
collections: { type: Array, default: () => [] },
last_macros_import: { type: Object, default: () => ({}) },
last_labels_import: { type: Object, default: () => ({}) },
})
const fields = [
// Macro configuration fields
{ key: 'macro_name', label: 'Makro-Name', placeholder: 'z.B. Copyright Makro', section: 'macro' },
{ key: 'macro_uuid', label: 'Makro-UUID', placeholder: 'z.B. 11111111-2222-3333-4444-555555555555', section: 'macro' },
{ key: 'macro_collection_name', label: 'Collection-Name', defaultValue: '--MAIN--', section: 'macro' },
{ key: 'macro_collection_uuid', label: 'Collection-UUID', defaultValue: '8D02FC57-83F8-4042-9B90-81C229728426', section: 'macro' },
// Agenda configuration fields
{ key: 'agenda_start_title', label: 'Ablauf-Start', placeholder: 'z.B. Ablauf* oder Beginn*', section: 'agenda' },
{ key: 'agenda_end_title', label: 'Ablauf-Ende', placeholder: 'z.B. Ende* oder Schluss*', section: 'agenda' },
{ key: 'agenda_announcement_position', label: 'Ankündigungen-Position', placeholder: 'z.B. Informationen*,Hinweise*', helpText: 'Komma-getrennte Liste. Das erste passende Element im Ablauf bestimmt, wo die Ankündigungsfolien eingefügt werden. * als Platzhalter.', section: 'agenda' },
{ key: 'agenda_sermon_matching', label: 'Predigt-Erkennung', placeholder: 'z.B. Predigt*,Sermon*', helpText: 'Komma-getrennte Liste. Erkannte Elemente bekommen einen Predigt-Upload-Bereich. * als Platzhalter.', section: 'agenda' },
const submenus = [
{ key: 'assignments', label: 'Makro-Zuweisungen' },
{ key: 'macros', label: 'Makro-Import' },
{ key: 'labels', label: 'Label-Import' },
{ key: 'agenda', label: 'Agenda' },
]
const form = reactive({})
for (const field of fields) {
form[field.key] = props.settings[field.key] ?? field.defaultValue ?? ''
}
const activeSubmenu = ref('assignments')
const saving = reactive({})
const saved = reactive({})
const errors = reactive({})
async function saveField(key) {
if (saving[key]) return
saving[key] = true
errors[key] = null
saved[key] = false
try {
const response = await fetch(route('settings.update'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
),
},
body: JSON.stringify({ key, value: form[key] || null }),
})
if (!response.ok) {
const data = await response.json()
errors[key] = data.message || 'Speichern fehlgeschlagen'
return
}
saved[key] = true
setTimeout(() => { saved[key] = false }, 2000)
} catch {
errors[key] = 'Netzwerkfehler beim Speichern'
} finally {
saving[key] = false
onMounted(() => {
const hash = window.location.hash.replace('#', '')
if (submenus.some((s) => s.key === hash)) {
activeSubmenu.value = hash
}
})
function switchSubmenu(key) {
activeSubmenu.value = key
window.location.hash = key
}
</script>
@ -79,148 +50,78 @@ async function saveField(key) {
</template>
<div class="py-8">
<div class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm">
<div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
<h3 class="text-sm font-semibold text-gray-900">
ProPresenter Makro-Konfiguration
</h3>
<p class="mt-1 text-xs text-gray-500">
Diese Einstellungen werden beim Export auf Copyright-Folien als Makro-Aktion angewendet.
</p>
</div>
<div class="divide-y divide-gray-100 px-6">
<div
v-for="field in fields.filter(f => f.section === 'macro')"
:key="field.key"
class="py-5"
>
<label
:for="'setting-' + field.key"
class="block text-sm font-medium text-gray-700"
>
{{ field.label }}
</label>
<div class="relative mt-1.5">
<input
:id="'setting-' + field.key"
:data-testid="'setting-' + field.key"
v-model="form[field.key]"
type="text"
:placeholder="field.placeholder || field.defaultValue || ''"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
:class="{
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
'border-emerald-300': saved[field.key],
}"
@blur="saveField(field.key)"
/>
<div
v-if="saving[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
<div class="flex min-h-[400px]">
<!-- Sidebar (desktop) -->
<div class="hidden w-48 shrink-0 border-r border-gray-100 sm:block">
<nav class="flex flex-col gap-1 p-2">
<button
v-for="item in submenus"
:key="item.key"
:data-testid="'settings-submenu-' + item.key"
@click="switchSubmenu(item.key)"
:class="[
'w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',
activeSubmenu === item.key
? 'border-l-2 border-amber-500 bg-amber-50 text-amber-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
]"
>
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<div
v-else-if="saved[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p
v-if="errors[field.key]"
class="mt-1.5 text-xs text-red-600"
:data-testid="'error-' + field.key"
>
{{ errors[field.key] }}
</p>
<p
v-if="field.defaultValue"
class="mt-1.5 text-xs text-gray-400"
>
Standard: {{ field.defaultValue }}
</p>
{{ item.label }}
</button>
</nav>
</div>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm mt-6">
<div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
<h3 class="text-sm font-semibold text-gray-900">Agenda-Konfiguration</h3>
<p class="mt-1 text-xs text-gray-500">Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.</p>
</div>
<div class="divide-y divide-gray-100 px-6">
<div v-for="field in fields.filter(f => f.section === 'agenda')" :key="field.key" class="py-5">
<label
:for="'setting-' + field.key"
class="block text-sm font-medium text-gray-700"
>
{{ field.label }}
</label>
<div class="relative mt-1.5">
<input
:id="'setting-' + field.key"
:data-testid="'setting-' + field.key"
v-model="form[field.key]"
type="text"
:placeholder="field.placeholder || field.defaultValue || ''"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
:class="{
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
'border-emerald-300': saved[field.key],
}"
@blur="saveField(field.key)"
/>
<div
v-if="saving[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
<!-- Mobile tab bar -->
<div class="w-full border-b border-gray-100 sm:hidden">
<nav class="flex overflow-x-auto">
<button
v-for="item in submenus"
:key="item.key"
:data-testid="'settings-submenu-' + item.key"
@click="switchSubmenu(item.key)"
:class="[
'shrink-0 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors',
activeSubmenu === item.key
? 'border-amber-500 text-amber-700'
: 'border-transparent text-gray-600 hover:text-gray-900',
]"
>
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
{{ item.label }}
</button>
</nav>
</div>
<div
v-else-if="saved[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<!-- Content panel -->
<div class="flex-1 p-6" data-testid="settings-active-panel">
<MacroAssignments
v-if="activeSubmenu === 'assignments'"
:assignments="assignments"
:macros="macros"
:labels="labels"
:collections="collections"
@switch-submenu="switchSubmenu"
/>
<p
v-if="errors[field.key]"
class="mt-1.5 text-xs text-red-600"
:data-testid="'error-' + field.key"
>
{{ errors[field.key] }}
</p>
<MacroImport
v-if="activeSubmenu === 'macros'"
:macros="macros"
:collections="collections"
:last_macros_import="last_macros_import"
@switch-submenu="switchSubmenu"
/>
<p v-if="field.helpText" class="mt-1.5 text-xs text-gray-400">{{ field.helpText }}</p>
<LabelImport
v-if="activeSubmenu === 'labels'"
:labels="labels"
:last_labels_import="last_labels_import"
/>
<p
v-if="field.defaultValue"
class="mt-1.5 text-xs text-gray-400"
>
Standard: {{ field.defaultValue }}
</p>
<AgendaSettings
v-if="activeSubmenu === 'agenda'"
:settings="settings"
/>
</div>
</div>
</div>

View file

@ -0,0 +1,139 @@
<script setup>
import { reactive } from 'vue'
import { route } from 'ziggy-js'
const props = defineProps({
settings: { type: Object, default: () => ({}) },
})
const fields = [
{ key: 'agenda_start_title', label: 'Ablauf-Start', placeholder: 'z.B. Ablauf* oder Beginn*' },
{ key: 'agenda_end_title', label: 'Ablauf-Ende', placeholder: 'z.B. Ende* oder Schluss*' },
{
key: 'agenda_announcement_position',
label: 'Ankündigungen-Position',
placeholder: 'z.B. Informationen*,Hinweise*',
helpText: 'Komma-getrennte Liste. Das erste passende Element im Ablauf bestimmt, wo die Ankündigungsfolien eingefügt werden. * als Platzhalter.',
},
{
key: 'agenda_sermon_matching',
label: 'Predigt-Erkennung',
placeholder: 'z.B. Predigt*,Sermon*',
helpText: 'Komma-getrennte Liste. Erkannte Elemente bekommen einen Predigt-Upload-Bereich. * als Platzhalter.',
},
]
const form = reactive({})
for (const field of fields) {
form[field.key] = props.settings[field.key] ?? ''
}
const saving = reactive({})
const saved = reactive({})
const errors = reactive({})
async function saveField(key) {
if (saving[key]) return
saving[key] = true
errors[key] = null
saved[key] = false
try {
const response = await fetch(route('settings.update'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
},
body: JSON.stringify({ key, value: form[key] || null }),
})
if (!response.ok) {
const data = await response.json()
errors[key] = data.message || 'Speichern fehlgeschlagen'
return
}
saved[key] = true
setTimeout(() => { saved[key] = false }, 2000)
} catch {
errors[key] = 'Netzwerkfehler beim Speichern'
} finally {
saving[key] = false
}
}
</script>
<template>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-1">
Agenda-Konfiguration
</h3>
<p class="text-xs text-gray-500 mb-4">
Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.
</p>
<div class="divide-y divide-gray-100">
<div
v-for="field in fields"
:key="field.key"
class="py-4"
>
<label
:for="'setting-' + field.key"
class="block text-sm font-medium text-gray-700"
>
{{ field.label }}
</label>
<div class="relative mt-1.5">
<input
:id="'setting-' + field.key"
:data-testid="'setting-' + field.key"
v-model="form[field.key]"
type="text"
:placeholder="field.placeholder || ''"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
:class="{
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
'border-emerald-300': saved[field.key],
}"
@blur="saveField(field.key)"
/>
<div
v-if="saving[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<div
v-else-if="saved[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p
v-if="errors[field.key]"
class="mt-1.5 text-xs text-red-600"
:data-testid="'error-' + field.key"
>
{{ errors[field.key] }}
</p>
<p
v-if="field.helpText"
class="mt-1.5 text-xs text-gray-400"
>
{{ field.helpText }}
</p>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,138 @@
<script setup>
import { computed, ref } from 'vue'
import { route } from 'ziggy-js'
const props = defineProps({
labels: { type: Array, default: () => [] },
last_labels_import: { type: Object, default: () => ({}) },
})
const uploading = ref(false)
const result = ref(null)
const error = ref(null)
const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name)))
async function handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
uploading.value = true
error.value = null
result.value = null
const form = new FormData()
form.append('file', file)
try {
const res = await fetch(route('settings.labels.import'), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
},
body: form,
})
if (!res.ok) {
const data = await res.json()
error.value = data.message || 'Import fehlgeschlagen'
return
}
result.value = await res.json()
event.target.value = ''
window.location.reload()
} catch {
error.value = 'Netzwerkfehler beim Upload'
} finally {
uploading.value = false
}
}
</script>
<template>
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Label-Import</h3>
<p class="mb-1 text-xs text-gray-500">
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
<span class="group relative inline-block">
<svg
class="ml-1 inline h-3.5 w-3.5 cursor-help text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="absolute bottom-full left-0 z-10 mb-2 hidden w-72 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg group-hover:block">
<strong>macOS:</strong> ~/Library/Application Support/RenewedVision/ProPresenter/Configuration/Labels<br><br>
<strong>Windows:</strong> %APPDATA%\RenewedVision\ProPresenter\Configuration\Labels
</span>
</span>
</p>
<div v-if="last_labels_import?.at" class="mb-4 text-xs text-gray-400">
Letzter Import: {{ last_labels_import.at }}
<span v-if="last_labels_import.filename">({{ last_labels_import.filename }})</span>
</div>
<label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
data-testid="labels-upload-area"
>
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span class="text-sm text-gray-500">
{{ uploading ? 'Wird importiert...' : 'Labels-Datei auswählen oder hierher ziehen' }}
</span>
<input
type="file"
class="hidden"
:disabled="uploading"
data-testid="labels-file-input"
@change="handleFileChange"
/>
</label>
<div
v-if="error"
class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700"
data-testid="labels-import-error"
>
{{ error }}
</div>
<div
v-if="result"
class="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-700"
data-testid="labels-import-summary"
>
<strong>Import abgeschlossen:</strong>
{{ result.new }} neue Labels importiert, {{ result.updated }} bestehende Labels aktualisiert.
Insgesamt {{ result.total }} Labels in Datei.
</div>
<div v-if="sortedLabels.length > 0">
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Label-Bibliothek ({{ sortedLabels.length }})
</h4>
<div class="divide-y divide-gray-100 rounded-lg border border-gray-100">
<div
v-for="label in sortedLabels"
:key="label.id"
class="flex items-center gap-3 px-3 py-2 text-sm"
:data-testid="'labels-registry-row-' + label.name"
>
<span
class="h-4 w-4 shrink-0 rounded border border-gray-200"
:style="label.color ? { backgroundColor: label.color } : { backgroundColor: '#e5e7eb' }"
/>
<span class="flex-1 text-gray-700">{{ label.name }}</span>
<span v-if="label.color" class="font-mono text-xs text-gray-400">{{ label.color }}</span>
</div>
</div>
</div>
<p v-else class="text-sm text-gray-400">Noch keine Labels importiert.</p>
</div>
</template>

View file

@ -0,0 +1,210 @@
<script setup>
import LabelPicker from '@/Components/LabelPicker.vue'
import MacroPicker from '@/Components/MacroPicker.vue'
import { router } from '@inertiajs/vue3'
import { reactive } from 'vue'
import { route } from 'ziggy-js'
const props = defineProps({
assignments: { type: Array, default: () => [] },
macros: { type: Array, default: () => [] },
labels: { type: Array, default: () => [] },
collections: { type: Array, default: () => [] },
})
const parts = [
{ key: 'information', label: 'Informationen' },
{ key: 'moderation', label: 'Moderation' },
{ key: 'sermon', label: 'Predigt' },
{ key: 'song', label: 'Lieder' },
{ key: 'agenda_item', label: 'Agenda-Items' },
]
const positions = [
{ key: 'all_slides', label: 'Alle Folien' },
{ key: 'first_slide', label: 'Erste Folie' },
{ key: 'last_slide', label: 'Letzte Folie' },
{ key: 'by_label', label: 'Nach Label' },
]
function assignmentsForPart(partKey) {
return props.assignments.filter((a) => a.part_type === partKey)
}
function positionsForPart(partKey) {
if (partKey === 'song') return positions
return positions.filter((p) => p.key !== 'by_label')
}
const adding = reactive({})
const newAssignment = reactive({})
function startAdd(partKey) {
adding[partKey] = true
newAssignment[partKey] = { macro_id: null, position: 'all_slides', label_id: null }
}
function cancelAdd(partKey) {
adding[partKey] = false
}
async function saveAssignment(partKey) {
const data = newAssignment[partKey]
if (!data.macro_id) return
await fetch(route('settings.macro-assignments.store'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
},
body: JSON.stringify({
part_type: partKey,
macro_id: data.macro_id,
position: data.position,
label_id: data.position === 'by_label' ? data.label_id : null,
order: assignmentsForPart(partKey).length,
}),
})
adding[partKey] = false
router.reload({ preserveScroll: true })
}
async function deleteAssignment(id) {
await fetch(route('settings.macro-assignments.destroy', id), {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
},
})
router.reload({ preserveScroll: true })
}
function positionLabel(pos) {
return positions.find((p) => p.key === pos)?.label ?? pos
}
</script>
<template>
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Globale Makro-Zuweisungen</h3>
<p class="mb-4 text-xs text-gray-500">
Diese Zuweisungen gelten für alle Gottesdienste. Pro Gottesdienst können sie überschrieben werden.
</p>
<div class="space-y-6">
<div v-for="part in parts" :key="part.key">
<div class="mb-2 flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-700">{{ part.label }}</h4>
<span class="text-xs text-gray-400">{{ assignmentsForPart(part.key).length }} Zuweisungen</span>
</div>
<div class="space-y-1">
<div
v-for="a in assignmentsForPart(part.key)"
:key="a.id"
class="flex items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
:data-testid="'assignment-card-' + a.id"
>
<span
v-if="a.macro?.color"
class="h-3 w-3 shrink-0 rounded"
:style="{ backgroundColor: a.macro.color }"
/>
<span class="flex-1 text-gray-700">{{ a.macro?.name }}</span>
<span class="text-xs text-gray-400">{{ positionLabel(a.position) }}</span>
<span
v-if="a.position === 'by_label' && a.label"
class="text-xs text-gray-500"
>
{{ a.label.name }}
</span>
<span
v-if="a.position === 'by_label' && a.label?.hidden_at"
class="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700"
data-testid="warning-hidden-label"
>
Label deaktiviert
</span>
<span
v-if="a.macro?.hidden_at"
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700"
data-testid="warning-hidden-macro"
>
Makro deaktiviert
</span>
<button
class="ml-2 text-gray-400 hover:text-red-500"
:data-testid="'delete-assignment-' + a.id"
@click="deleteAssignment(a.id)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div
v-if="adding[part.key]"
class="mt-2 space-y-2 rounded-lg border border-amber-200 bg-amber-50 p-3"
>
<MacroPicker
v-model="newAssignment[part.key].macro_id"
:macros="macros"
:collections="collections"
/>
<div class="flex flex-wrap gap-3">
<label
v-for="pos in positionsForPart(part.key)"
:key="pos.key"
class="flex items-center gap-1.5 text-xs text-gray-700"
>
<input
type="radio"
:value="pos.key"
v-model="newAssignment[part.key].position"
class="text-amber-500"
/>
{{ pos.label }}
</label>
</div>
<LabelPicker
v-if="newAssignment[part.key]?.position === 'by_label'"
v-model="newAssignment[part.key].label_id"
:labels="labels"
/>
<div class="flex gap-2">
<button
class="rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600"
:data-testid="'save-assignment-' + part.key"
@click="saveAssignment(part.key)"
>
Hinzufügen
</button>
<button
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
@click="cancelAdd(part.key)"
>
Abbrechen
</button>
</div>
</div>
<button
v-else
class="mt-1 flex items-center gap-1 rounded-lg border border-dashed border-gray-200 px-3 py-1.5 text-xs text-gray-500 transition-colors hover:border-amber-300 hover:text-amber-600"
:data-testid="'add-assignment-' + part.key"
@click="startAdd(part.key)"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Zuweisung hinzufügen
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,181 @@
<script setup>
import { computed, ref } from 'vue'
import { route } from 'ziggy-js'
const props = defineProps({
macros: { type: Array, default: () => [] },
collections: { type: Array, default: () => [] },
last_macros_import: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['switch-submenu'])
const uploading = ref(false)
const result = ref(null)
const error = ref(null)
const selectedCollection = ref(null)
const filteredMacros = computed(() => {
if (!selectedCollection.value) return props.macros
const coll = props.collections.find((c) => c.id === selectedCollection.value)
if (!coll) return props.macros
const ids = coll.macros?.map((m) => m.id) ?? []
return props.macros.filter((m) => ids.includes(m.id))
})
async function handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
uploading.value = true
error.value = null
result.value = null
const form = new FormData()
form.append('file', file)
try {
const res = await fetch(route('settings.macros.import'), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
},
body: form,
})
if (!res.ok) {
const data = await res.json()
error.value = data.message || 'Import fehlgeschlagen'
return
}
result.value = await res.json()
event.target.value = ''
window.location.reload()
} catch {
error.value = 'Netzwerkfehler beim Upload'
} finally {
uploading.value = false
}
}
</script>
<template>
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Makro-Import</h3>
<p class="mb-1 text-xs text-gray-500">
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
<span class="group relative inline-block">
<svg
class="ml-1 inline h-3.5 w-3.5 cursor-help text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="absolute bottom-full left-0 z-10 mb-2 hidden w-72 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg group-hover:block">
<strong>macOS:</strong> ~/Library/Application Support/RenewedVision/ProPresenter/Configuration/Macros<br><br>
<strong>Windows:</strong> %APPDATA%\RenewedVision\ProPresenter\Configuration\Macros
</span>
</span>
</p>
<div v-if="last_macros_import?.at" class="mb-4 text-xs text-gray-400">
Letzter Import: {{ last_macros_import.at }}
<span v-if="last_macros_import.filename">({{ last_macros_import.filename }})</span>
</div>
<label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
data-testid="macros-upload-area"
>
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span class="text-sm text-gray-500">
{{ uploading ? 'Wird importiert...' : 'Makro-Datei auswählen oder hierher ziehen' }}
</span>
<input
type="file"
class="hidden"
:disabled="uploading"
data-testid="macros-file-input"
@change="handleFileChange"
/>
</label>
<div
v-if="error"
class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700"
data-testid="macros-import-error"
>
{{ error }}
</div>
<div
v-if="result"
class="mb-4 space-y-1 rounded-lg bg-green-50 p-3 text-sm text-green-700"
data-testid="macros-import-summary"
>
<p><strong>Import abgeschlossen:</strong></p>
<p>{{ result.stats.new }} neue Makros importiert</p>
<p>{{ result.stats.updated }} bestehende Makros aktualisiert</p>
<p>{{ result.stats.disabled }} Makros deaktiviert (nicht mehr in Datei vorhanden)</p>
<p v-if="result.stats.re_enabled > 0">{{ result.stats.re_enabled }} Makros wieder aktiviert</p>
</div>
<div
v-if="result?.warnings?.length > 0"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
data-testid="macros-import-warnings"
>
<p class="mb-2 font-semibold"> Achtung: Folgende deaktivierte Makros sind noch zugewiesen:</p>
<ul class="space-y-1 text-xs">
<li v-for="w in result.warnings" :key="w.macro_uuid">
{{ w.macro_name }} (Bereich: {{ w.part_type }})
</li>
</ul>
<button
class="mt-2 text-xs text-amber-700 underline"
@click="emit('switch-submenu', 'assignments')"
>
Zu den Makro-Zuweisungen
</button>
</div>
<div v-if="macros.length > 0">
<div class="mb-2 flex items-center gap-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Makro-Bibliothek ({{ macros.length }})
</h4>
<select
v-model="selectedCollection"
class="ml-auto rounded border-gray-300 text-xs"
data-testid="macros-collection-filter"
>
<option :value="null">Alle Sammlungen</option>
<option v-for="c in collections" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="divide-y divide-gray-100 rounded-lg border border-gray-100">
<div
v-for="macro in filteredMacros"
:key="macro.id"
class="flex items-center gap-3 px-3 py-2 text-sm"
:class="macro.hidden_at ? 'opacity-50' : ''"
>
<span
class="h-4 w-4 shrink-0 rounded border border-gray-200"
:style="macro.color ? { backgroundColor: macro.color } : { backgroundColor: '#e5e7eb' }"
/>
<span class="flex-1 text-gray-700">
{{ macro.name }}{{ macro.hidden_at ? ' (deaktiviert)' : '' }}
</span>
<span class="text-xs text-gray-400">{{ macro.action_count }} Aktionen</span>
</div>
</div>
</div>
<p v-else class="text-sm text-gray-400">Noch keine Makros importiert.</p>
</div>
</template>

View file

@ -2,7 +2,11 @@
use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\LabelImportController;
use App\Http\Controllers\MacroAssignmentController;
use App\Http\Controllers\MacroImportController;
use App\Http\Controllers\ServiceController;
use App\Http\Controllers\ServiceMacroOverrideController;
use App\Http\Controllers\SettingsController;
use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\SyncController;
@ -90,4 +94,34 @@
Route::post('/slides/reorder', '\\App\\Http\\Controllers\\SlideController@reorder')->name('slides.reorder');
Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy');
Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date');
/*
|--------------------------------------------------------------------------
| Makro- und Label-Import (ProPresenter)
|--------------------------------------------------------------------------
*/
Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import');
Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import');
/*
|--------------------------------------------------------------------------
| Globale Makro-Zuweisungen
|--------------------------------------------------------------------------
*/
Route::get('/settings/macro-assignments', [MacroAssignmentController::class, 'index'])->name('settings.macro-assignments.index');
Route::post('/settings/macro-assignments/reorder', [MacroAssignmentController::class, 'reorder'])->name('settings.macro-assignments.reorder');
Route::post('/settings/macro-assignments', [MacroAssignmentController::class, 'store'])->name('settings.macro-assignments.store');
Route::patch('/settings/macro-assignments/{macroAssignment}', [MacroAssignmentController::class, 'update'])->name('settings.macro-assignments.update');
Route::delete('/settings/macro-assignments/{macroAssignment}', [MacroAssignmentController::class, 'destroy'])->name('settings.macro-assignments.destroy');
/*
|--------------------------------------------------------------------------
| Service-spezifische Makro-Overrides
|--------------------------------------------------------------------------
*/
Route::post('/services/{service}/macro-overrides', [ServiceMacroOverrideController::class, 'store'])->name('services.macro-overrides.store');
Route::delete('/services/{service}/macro-overrides', [ServiceMacroOverrideController::class, 'destroy'])->name('services.macro-overrides.destroy');
Route::post('/services/{service}/macro-assignments', [ServiceMacroOverrideController::class, 'storeAssignment'])->name('services.macro-assignments.store');
Route::patch('/services/{service}/macro-assignments/{serviceMacroAssignment}', [ServiceMacroOverrideController::class, 'updateAssignment'])->name('services.macro-assignments.update');
Route::delete('/services/{service}/macro-assignments/{serviceMacroAssignment}', [ServiceMacroOverrideController::class, 'destroyAssignment'])->name('services.macro-assignments.destroy');
});

View file

@ -2,12 +2,14 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -102,8 +104,18 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
$service = Service::factory()->create();
$song = Song::factory()->create(['title' => 'Amazing Grace']);
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]);
SongSlide::factory()->create(['song_group_id' => $group->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
$label = Label::factory()->create(['name' => 'Verse 1']);
SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'is_default' => true,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => 1,
]);
$serviceSong = ServiceSong::create([
'service_id' => $service->id,

View file

@ -2,10 +2,10 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
@ -34,19 +34,19 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
$this->assertNotNull($newArrangement);
$defaultGroupOrder = SongGroup::query()
->where('song_id', $song->id)
$defaultLabelOrder = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('id')
->pluck('label_id')
->all();
$newGroups = SongArrangementGroup::query()
$newLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $newArrangement->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$this->assertSame($defaultGroupOrder, $newGroups);
$this->assertSame($defaultLabelOrder, $newLabels);
}
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void
@ -69,19 +69,19 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups():
$this->assertNotNull($clone);
$this->assertFalse($clone->is_default);
$originalGroups = SongArrangementGroup::query()
$originalLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$cloneGroups = SongArrangementGroup::query()
$cloneLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $clone->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$this->assertSame($originalGroups, $cloneGroups);
$this->assertSame($originalLabels, $cloneLabels);
}
public function test_update_arrangement_reorders_and_persists_groups(): void
@ -92,19 +92,19 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
$response = $this->put(route('arrangements.update', $normal), [
'groups' => [
['song_group_id' => $chorus->id, 'order' => 1],
['song_group_id' => $bridge->id, 'order' => 2],
['song_group_id' => $verse->id, 'order' => 3],
['song_group_id' => $chorus->id, 'order' => 4],
['label_id' => $chorus->id, 'order' => 1],
['label_id' => $bridge->id, 'order' => 2],
['label_id' => $verse->id, 'order' => 3],
['label_id' => $chorus->id, 'order' => 4],
],
]);
$response->assertRedirect();
$updated = SongArrangementGroup::query()
$updated = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$this->assertSame([
@ -136,23 +136,9 @@ private function createSongWithDefaultArrangement(): array
{
$song = Song::factory()->create();
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Verse 1',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Chorus',
'order' => 2,
]);
$bridge = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Bridge',
'order' => 3,
]);
$verse = Label::factory()->create(['name' => 'Verse 1']);
$chorus = Label::factory()->create(['name' => 'Chorus']);
$bridge = Label::factory()->create(['name' => 'Bridge']);
$normal = SongArrangement::factory()->create([
'song_id' => $song->id,
@ -160,21 +146,21 @@ private function createSongWithDefaultArrangement(): array
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 2,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 3,
]);

View file

@ -6,8 +6,6 @@
use App\Models\Slide;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongSlide;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
@ -26,13 +24,18 @@
'failed_jobs',
'services',
'songs',
'song_groups',
'labels',
'song_slides',
'song_arrangements',
'song_arrangement_groups',
'song_arrangement_labels',
'service_songs',
'slides',
'cts_sync_log',
'macros',
'macro_collections',
'macro_assignments',
'service_macro_overrides',
'service_macro_assignments',
];
foreach ($expectedTables as $table) {
@ -40,23 +43,24 @@
}
});
test('dropped tables no longer exist', function () {
expect(Schema::hasTable('song_groups'))->toBeFalse();
expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse();
});
test('all factories create valid records', function () {
Service::factory()->create();
Song::factory()->create();
SongGroup::factory()->create();
SongSlide::factory()->create();
SongArrangement::factory()->create();
SongArrangementGroup::factory()->create();
ServiceSong::factory()->create();
Slide::factory()->create();
CtsSyncLog::factory()->create();
expect(Service::count())->toBeGreaterThan(0)
->and(Song::count())->toBeGreaterThan(0)
->and(SongGroup::count())->toBeGreaterThan(0)
->and(SongSlide::count())->toBeGreaterThan(0)
->and(SongArrangement::count())->toBeGreaterThan(0)
->and(SongArrangementGroup::count())->toBeGreaterThan(0)
->and(ServiceSong::count())->toBeGreaterThan(0)
->and(Slide::count())->toBeGreaterThan(0)
->and(CtsSyncLog::count())->toBeGreaterThan(0);

View file

@ -0,0 +1,33 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
uses(RefreshDatabase::class);
test('label import requires authentication', function () {
$response = $this->post(route('settings.labels.import'), []);
$response->assertRedirect(route('login'));
});
test('label import returns json on valid file', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('settings.labels.import'), [
'file' => new UploadedFile(base_path('tests/fixtures/labels-sample.bin'), 'labels.bin', null, null, true),
]);
$response->assertStatus(200)
->assertJsonStructure(['new', 'updated', 'total']);
});
test('label import returns 422 on invalid file', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('settings.labels.import'), [
'file' => UploadedFile::fake()->create('notalabels.bin', 1),
]);
$response->assertStatus(422);
});

View file

@ -0,0 +1,53 @@
<?php
use App\Models\Label;
use App\Models\Setting;
use App\Services\LabelsImportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('import creates new labels from file', function () {
$service = app(LabelsImportService::class);
$result = $service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect($result->newCount)->toBeGreaterThanOrEqual(1);
expect($result->updatedCount)->toBe(0);
expect(Label::count())->toBeGreaterThanOrEqual(1);
});
test('import updates color of existing labels', function () {
Label::create(['name' => 'Copyright', 'color' => '#000000']);
$service = app(LabelsImportService::class);
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
$label = Label::where('name', 'Copyright')->first();
if ($label) {
expect($label->color)->not->toBe('#000000');
}
expect(true)->toBeTrue();
});
test('import stores last imported metadata in settings', function () {
$service = app(LabelsImportService::class);
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect(Setting::get('labels_last_imported_at'))->not->toBeNull();
expect(Setting::get('labels_last_imported_filename'))->toBe('labels-sample.bin');
});
test('re-import is idempotent — no duplicates', function () {
$service = app(LabelsImportService::class);
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
$countAfterFirst = Label::count();
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect(Label::count())->toBe($countAfterFirst);
});
test('empty label names are skipped', function () {
$service = app(LabelsImportService::class);
$result = $service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
expect($result->totalInFile)->toBeGreaterThan(0);
});

View file

@ -0,0 +1,108 @@
<?php
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('store requires authentication', function () {
$response = $this->post(route('settings.macro-assignments.store'), []);
$response->assertRedirect(route('login'));
});
test('store creates macro assignment', function () {
$user = User::factory()->create();
$macro = Macro::factory()->create();
$response = $this->actingAs($user)
->postJson(route('settings.macro-assignments.store'), [
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$response->assertStatus(200)->assertJson(['success' => true]);
expect(MacroAssignment::count())->toBe(1);
expect(MacroAssignment::first()->part_type)->toBe('song');
});
test('store with by_label position and label_id', function () {
$user = User::factory()->create();
$macro = Macro::factory()->create();
$label = Label::factory()->create();
$response = $this->actingAs($user)
->postJson(route('settings.macro-assignments.store'), [
'part_type' => 'sermon',
'macro_id' => $macro->id,
'position' => 'by_label',
'label_id' => $label->id,
'order' => 1,
]);
$response->assertStatus(200);
$assignment = MacroAssignment::first();
expect($assignment->label_id)->toBe($label->id);
expect($assignment->position)->toBe('by_label');
});
test('update modifies existing assignment', function () {
$user = User::factory()->create();
$macro = Macro::factory()->create();
$assignment = MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$response = $this->actingAs($user)
->patchJson(route('settings.macro-assignments.update', $assignment), [
'position' => 'first_slide',
'order' => 5,
]);
$response->assertStatus(200)->assertJson(['success' => true]);
expect($assignment->fresh()->position)->toBe('first_slide');
expect($assignment->fresh()->order)->toBe(5);
});
test('destroy deletes macro assignment', function () {
$user = User::factory()->create();
$macro = Macro::factory()->create();
$assignment = MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$response = $this->actingAs($user)
->deleteJson(route('settings.macro-assignments.destroy', $assignment));
$response->assertStatus(200)->assertJson(['success' => true]);
expect(MacroAssignment::count())->toBe(0);
});
test('reorder updates order on multiple assignments', function () {
$user = User::factory()->create();
$macro = Macro::factory()->create();
$a1 = MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$a2 = MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 1]);
$response = $this->actingAs($user)
->postJson(route('settings.macro-assignments.reorder'), [
'assignments' => [
['id' => $a1->id, 'order' => 5],
['id' => $a2->id, 'order' => 3],
],
]);
$response->assertStatus(200);
expect($a1->fresh()->order)->toBe(5);
expect($a2->fresh()->order)->toBe(3);
});

View file

@ -0,0 +1,41 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
uses(RefreshDatabase::class);
test('macro import requires authentication', function () {
$response = $this->post(route('settings.macros.import'), []);
$response->assertRedirect(route('login'));
});
test('macro import returns json with stats on valid file', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('settings.macros.import'), [
'file' => new UploadedFile(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin', null, null, true),
]);
$response->assertStatus(200)
->assertJsonStructure(['stats' => ['new', 'updated', 'disabled', 're_enabled'], 'warnings']);
});
test('macro import returns 422 on invalid file', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('settings.macros.import'), [
'file' => UploadedFile::fake()->create('notamacro.bin', 1),
]);
$response->assertStatus(422);
});
test('macro import without file returns validation error', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson(route('settings.macros.import'), []);
$response->assertStatus(422);
});

View file

@ -0,0 +1,115 @@
<?php
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use App\Services\MacroResolutionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('returns empty when no assignments exist', function () {
$service = Service::factory()->create();
$resolver = app(MacroResolutionService::class);
expect($resolver->resolveAssignmentsForPart($service, 'song'))->toBeEmpty();
});
test('returns global assignments when no override', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
$resolved = $resolver->resolveAssignmentsForPart($service, 'song');
expect($resolved)->toHaveCount(1);
expect($resolved->first()->macro->id)->toBe($macro->id);
});
test('override wins over globals', function () {
$service = Service::factory()->create();
$macroA = Macro::factory()->create(['name' => 'Global']);
$macroB = Macro::factory()->create(['name' => 'Override']);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macroA->id, 'position' => 'all_slides', 'order' => 0]);
ServiceMacroOverride::create(['service_id' => $service->id, 'part_type' => 'song']);
ServiceMacroAssignment::create(['service_id' => $service->id, 'part_type' => 'song', 'macro_id' => $macroB->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
$resolved = $resolver->resolveAssignmentsForPart($service, 'song');
expect($resolved)->toHaveCount(1);
expect($resolved->first()->macro->name)->toBe('Override');
});
test('hidden macros are filtered out', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create(['hidden_at' => now()]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->resolveAssignmentsForPart($service, 'song'))->toBeEmpty();
});
test('macrosForSlide with all_slides matches every slide', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
$result = $resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]);
expect($result)->toHaveCount(1);
expect($result[0]['uuid'])->toBe($macro->uuid);
});
test('macrosForSlide with first_slide only matches index 0', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'first_slide', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]))->toHaveCount(1);
expect($resolver->macrosForSlide($service, 'song', ['index' => 1, 'total' => 3, 'label_id' => null]))->toHaveCount(0);
});
test('macrosForSlide stacking — multiple matching assignments produce multiple macros', function () {
$service = Service::factory()->create();
$macro1 = Macro::factory()->create();
$macro2 = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro1->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro2->id, 'position' => 'first_slide', 'order' => 1]);
$resolver = app(MacroResolutionService::class);
$result = $resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]);
expect($result)->toHaveCount(2);
});
test('macrosForSlide with by_label matches only matching label_id', function () {
$service = Service::factory()->create();
$label = Label::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'by_label', 'label_id' => $label->id, 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => $label->id]))->toHaveCount(1);
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => 9999]))->toHaveCount(0);
});
test('countAssignmentsForPart returns correct count', function () {
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$resolver = app(MacroResolutionService::class);
expect($resolver->countAssignmentsForPart($service, 'song'))->toBe(1);
expect($resolver->countAssignmentsForPart($service, 'sermon'))->toBe(1);
expect($resolver->countAssignmentsForPart($service, 'information'))->toBe(0);
});

View file

@ -0,0 +1,69 @@
<?php
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Setting;
use App\Services\MacrosImportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('import creates new macros from file', function () {
$service = app(MacrosImportService::class);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect($result->new)->toBeGreaterThanOrEqual(1);
expect($result->updated)->toBe(0);
expect(Macro::count())->toBeGreaterThanOrEqual(1);
});
test('import stores hex color on macros', function () {
$service = app(MacrosImportService::class);
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect(Macro::whereNotNull('color')->where('color', 'like', '#%')->count())->toBeGreaterThanOrEqual(1);
});
test('import marks missing macros as hidden', function () {
$existing = Macro::factory()->create(['uuid' => 'FAKE-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'hidden_at' => null]);
$service = app(MacrosImportService::class);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect($existing->fresh()->isHidden())->toBeTrue();
expect($result->disabled)->toBeGreaterThanOrEqual(1);
});
test('import re-enables previously hidden macros that appear in file', function () {
$service = app(MacrosImportService::class);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
$firstMacro = Macro::first();
$firstMacro->update(['hidden_at' => now()]);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect($result->reEnabled)->toBeGreaterThanOrEqual(1);
expect($firstMacro->fresh()->isHidden())->toBeFalse();
});
test('import builds warnings for disabled macros with active assignments', function () {
$service = app(MacrosImportService::class);
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
$hiddenMacro = Macro::factory()->create(['uuid' => 'WARN-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'hidden_at' => now()]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $hiddenMacro->id, 'position' => 'all_slides', 'order' => 0]);
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect(count($result->warnings))->toBeGreaterThanOrEqual(1);
$warning = collect($result->warnings)->firstWhere('macro_uuid', 'WARN-FFFF-FFFF-FFFF-FFFFFFFFFFFF');
expect($warning)->not->toBeNull();
});
test('import stores last imported metadata in settings', function () {
$service = app(MacrosImportService::class);
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
expect(Setting::get('macros_last_imported_at'))->not->toBeNull();
expect(Setting::get('macros_last_imported_filename'))->toBe('macros.bin');
});

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('labels table has expected columns', function () {
expect(Schema::hasTable('labels'))->toBeTrue();
expect(Schema::hasColumns('labels', ['id', 'name', 'color', 'hidden_at', 'last_imported_at', 'created_at', 'updated_at']))->toBeTrue();
});
test('labels table enforces unique name', function () {
DB::table('labels')->insert(['name' => 'Vers 1', 'color' => '#FF0080', 'created_at' => now(), 'updated_at' => now()]);
expect(fn () => DB::table('labels')->insert(['name' => 'Vers 1', 'color' => '#000000', 'created_at' => now(), 'updated_at' => now()]))
->toThrow(\Exception::class);
});
test('labels table allows nullable color', function () {
DB::table('labels')->insert(['name' => 'TestLabel', 'color' => null, 'created_at' => now(), 'updated_at' => now()]);
expect(DB::table('labels')->where('name', 'TestLabel')->value('color'))->toBeNull();
});

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('macro_assignments table has expected columns', function () {
expect(Schema::hasTable('macro_assignments'))->toBeTrue();
expect(Schema::hasColumns('macro_assignments', ['id', 'part_type', 'macro_id', 'position', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
});
test('macro_assignments restrictOnDelete prevents deleting referenced macro', function () {
$macroId = DB::table('macros')->insertGetId(['uuid' => 'AABB-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
$labelId = DB::table('labels')->insertGetId(['name' => 'Copyright', 'created_at' => now(), 'updated_at' => now()]);
DB::table('macro_assignments')->insert(['part_type' => 'song', 'macro_id' => $macroId, 'position' => 'by_label', 'label_id' => $labelId, 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
expect(fn () => DB::table('macros')->where('id', $macroId)->delete())
->toThrow(\Exception::class);
});
test('macro_assignments allows nullable label_id', function () {
$macroId = DB::table('macros')->insertGetId(['uuid' => 'CCBB-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test2', 'created_at' => now(), 'updated_at' => now()]);
DB::table('macro_assignments')->insert(['part_type' => 'sermon', 'macro_id' => $macroId, 'position' => 'all_slides', 'label_id' => null, 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
expect(DB::table('macro_assignments')->where('macro_id', $macroId)->value('label_id'))->toBeNull();
});

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('macros table has expected columns', function () {
expect(Schema::hasTable('macros'))->toBeTrue();
expect(Schema::hasColumns('macros', [
'id', 'uuid', 'name', 'color', 'trigger_on_startup', 'image_type',
'action_count', 'hidden_at', 'last_imported_at', 'last_imported_filename',
'created_at', 'updated_at',
]))->toBeTrue();
});
test('macro_collections table has expected columns', function () {
expect(Schema::hasTable('macro_collections'))->toBeTrue();
expect(Schema::hasColumns('macro_collections', ['id', 'uuid', 'name', 'last_imported_at', 'created_at', 'updated_at']))->toBeTrue();
});
test('macro_collection_macros junction has expected columns', function () {
expect(Schema::hasTable('macro_collection_macros'))->toBeTrue();
expect(Schema::hasColumns('macro_collection_macros', ['id', 'macro_collection_id', 'macro_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
});
test('deleting a collection cascades to junction rows', function () {
$collId = DB::table('macro_collections')->insertGetId(['uuid' => 'COLL-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
$macroId = DB::table('macros')->insertGetId(['uuid' => 'AAAA-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
DB::table('macro_collection_macros')->insert(['macro_collection_id' => $collId, 'macro_id' => $macroId, 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
DB::table('macro_collections')->where('id', $collId)->delete();
expect(DB::table('macro_collection_macros')->where('macro_collection_id', $collId)->count())->toBe(0);
});
test('macros uuid is unique', function () {
DB::table('macros')->insert(['uuid' => 'SAME-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'First', 'created_at' => now(), 'updated_at' => now()]);
expect(fn () => DB::table('macros')->insert(['uuid' => 'SAME-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Second', 'created_at' => now(), 'updated_at' => now()]))
->toThrow(\Exception::class);
});

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Support\Facades\DB;
test('migration is no-op when macro settings are empty', function () {
expect(DB::table('macro_assignments')->count())->toBe(0);
expect(DB::table('macros')->count())->toBe(0);
});
test('migration creates assignment when all 4 keys are present', function () {
DB::table('settings')->insert([
['key' => 'macro_name', 'value' => 'Copyright Makro', 'created_at' => now(), 'updated_at' => now()],
['key' => 'macro_uuid', 'value' => 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF', 'created_at' => now(), 'updated_at' => now()],
['key' => 'macro_collection_name', 'value' => '--MAIN--', 'created_at' => now(), 'updated_at' => now()],
['key' => 'macro_collection_uuid', 'value' => '8D02FC57-83F8-4042-9B90-81C229728426', 'created_at' => now(), 'updated_at' => now()],
]);
$migration = require database_path('migrations/2026_05_03_100700_migrate_legacy_macro_settings.php');
$migration->up();
expect(DB::table('labels')->where('name', 'Copyright')->exists())->toBeTrue();
expect(DB::table('macros')->where('uuid', 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF')->exists())->toBeTrue();
expect(DB::table('macro_assignments')->where('part_type', 'song')->where('position', 'by_label')->exists())->toBeTrue();
expect(DB::table('settings')->whereIn('key', ['macro_name', 'macro_uuid', 'macro_collection_name', 'macro_collection_uuid'])->count())->toBe(0);
});
test('migration works when only name and uuid are set (no collection)', function () {
DB::table('settings')->insert([
['key' => 'macro_name', 'value' => 'Simple Makro', 'created_at' => now(), 'updated_at' => now()],
['key' => 'macro_uuid', 'value' => 'BBBBBBBB-1111-2222-3333-FFFFFFFFFFFF', 'created_at' => now(), 'updated_at' => now()],
]);
$migration = require database_path('migrations/2026_05_03_100700_migrate_legacy_macro_settings.php');
$migration->up();
expect(DB::table('macro_assignments')->where('part_type', 'song')->exists())->toBeTrue();
expect(DB::table('macro_collections')->count())->toBe(0);
});
test('migration is no-op when macro_uuid is empty', function () {
DB::table('settings')->insert([
['key' => 'macro_name', 'value' => 'Makro Ohne UUID', 'created_at' => now(), 'updated_at' => now()],
['key' => 'macro_uuid', 'value' => '', 'created_at' => now(), 'updated_at' => now()],
]);
$migration = require database_path('migrations/2026_05_03_100700_migrate_legacy_macro_settings.php');
$migration->up();
expect(DB::table('macro_assignments')->count())->toBe(0);
});

View file

@ -0,0 +1,35 @@
<?php
use App\Models\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('service_macro_overrides table has expected columns', function () {
expect(Schema::hasTable('service_macro_overrides'))->toBeTrue();
expect(Schema::hasColumns('service_macro_overrides', ['id', 'service_id', 'part_type', 'created_at', 'updated_at']))->toBeTrue();
});
test('service_macro_assignments table has expected columns', function () {
expect(Schema::hasTable('service_macro_assignments'))->toBeTrue();
expect(Schema::hasColumns('service_macro_assignments', ['id', 'service_id', 'part_type', 'macro_id', 'position', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
});
test('service_macro_overrides unique constraint on service_id and part_type', function () {
$service = Service::factory()->create();
DB::table('service_macro_overrides')->insert(['service_id' => $service->id, 'part_type' => 'song', 'created_at' => now(), 'updated_at' => now()]);
expect(fn () => DB::table('service_macro_overrides')->insert(['service_id' => $service->id, 'part_type' => 'song', 'created_at' => now(), 'updated_at' => now()]))
->toThrow(\Exception::class);
});
test('service delete cascades to overrides and assignments', function () {
$service = Service::factory()->create();
$macroId = DB::table('macros')->insertGetId(['uuid' => 'CCDD-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
DB::table('service_macro_overrides')->insert(['service_id' => $service->id, 'part_type' => 'sermon', 'created_at' => now(), 'updated_at' => now()]);
DB::table('service_macro_assignments')->insert(['service_id' => $service->id, 'part_type' => 'sermon', 'macro_id' => $macroId, 'position' => 'all_slides', 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
$service->delete();
expect(DB::table('service_macro_overrides')->where('service_id', $service->id)->count())->toBe(0);
expect(DB::table('service_macro_assignments')->where('service_id', $service->id)->count())->toBe(0);
});

View file

@ -0,0 +1,21 @@
<?php
use Illuminate\Support\Facades\Schema;
test('song_slides has label_id column after migration', function () {
expect(Schema::hasColumn('song_slides', 'label_id'))->toBeTrue();
expect(Schema::hasColumn('song_slides', 'song_group_id'))->toBeFalse();
});
test('song_arrangement_groups table is dropped', function () {
expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse();
});
test('song_arrangement_labels table exists with expected columns', function () {
expect(Schema::hasTable('song_arrangement_labels'))->toBeTrue();
expect(Schema::hasColumns('song_arrangement_labels', ['id', 'song_arrangement_id', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
});
test('song_groups table is dropped', function () {
expect(Schema::hasTable('song_groups'))->toBeFalse();
});

View file

@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('wipe migration is no-op on fresh database', function () {
expect(DB::table('songs')->count())->toBe(0);
expect(Schema::hasTable('songs'))->toBeTrue();
expect(Schema::hasTable('song_groups'))->toBeFalse();
});
test('wipe migration guard: song_groups does not exist after schema refactor', function () {
expect(Schema::hasTable('song_groups'))->toBeFalse();
});

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
@ -35,15 +36,21 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
'copyright_text' => 'Test Publisher',
]);
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse = Label::firstOrCreate(
['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'],
);
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$chorus = Label::firstOrCreate(
['name' => 'Chorus - '.$title],
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
return $song;
}

View file

@ -2,8 +2,14 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Service;
use App\Models\Song;
use App\Models\User;
use App\Services\ProExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -22,17 +28,23 @@ private function createSongWithContent(): Song
'publisher' => 'Test Publisher',
]);
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$verse = Label::firstOrCreate(
['name' => 'Verse 1 - Export Test Song'],
['color' => '#2196F3'],
);
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$chorus = Label::firstOrCreate(
['name' => 'Chorus - Export Test Song'],
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]);
return $song;
}
@ -82,7 +94,9 @@ public function test_download_pro_roundtrip_import_export(): void
$song = Song::find($songId);
$this->assertNotNull($song);
$this->assertGreaterThan(0, $song->groups()->count());
$labelCount = $song->arrangements()->withCount('arrangementLabels')->get()->sum('arrangement_labels_count');
$this->assertGreaterThan(0, $labelCount);
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk();
@ -92,7 +106,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
{
$user = User::factory()->create();
// 1. Import the reference .pro file
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
@ -100,43 +113,45 @@ public function test_download_pro_roundtrip_preserves_content(): void
$importResponse->assertOk();
$songId = $importResponse->json('songs.0.id');
$originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId);
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId);
$this->assertNotNull($originalSong);
// Snapshot original data
$originalGroups = $originalSong->groups->sortBy('order')->values();
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
$this->assertNotNull($defaultArr);
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements;
// 2. Export as .pro
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk();
// Save exported content to temp file — BinaryFileResponse delivers a real file
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
$baseResponse = $exportResponse->baseResponse;
copy($baseResponse->getFile()->getPathname(), $tempPath);
// 3. Re-import the exported file as a new song (different ccli to avoid upsert)
// Use the ProPresenter parser directly to read and verify
$reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
@unlink($tempPath);
// 4. Assert song name
$this->assertSame($originalSong->title, $reImported->getName());
// 5. Assert groups match (same names, same order)
$reImportedGroups = $reImported->getGroups();
$this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch');
foreach ($originalGroups as $index => $originalGroup) {
$uniqueOriginalLabels = $originalArrangementLabels
->map(fn ($al) => $al->label)
->filter()
->unique('id')
->values();
$this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch');
foreach ($uniqueOriginalLabels as $index => $originalLabel) {
$reImportedGroup = $reImportedGroups[$index];
$this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
// Assert slides within group
$originalSlides = $originalGroup->slides->sortBy('order')->values();
$originalSlides = $originalLabel->songSlides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'");
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'");
foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex];
@ -144,32 +159,30 @@ public function test_download_pro_roundtrip_preserves_content(): void
$this->assertSame(
$originalSlide->text_content,
$reImportedSlide->getPlainText(),
"Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
);
// Assert translation if present
if ($originalSlide->text_content_translated) {
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}");
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}");
$this->assertSame(
$originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(),
"Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
);
}
}
}
// 6. Assert arrangements match (same names, same group order)
$reImportedArrangements = $reImported->getArrangements();
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
foreach ($originalArrangements as $index => $originalArrangement) {
foreach ($originalArrangements as $originalArrangement) {
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementGroups
$originalGroupNames = $originalArrangement->arrangementLabels
->sortBy('order')
->map(fn ($ag) => $ag->group?->name)
->map(fn ($al) => $al->label?->name)
->filter()
->values()
->toArray();
@ -186,7 +199,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
);
}
// 7. Assert CCLI metadata
if ($originalSong->ccli_id) {
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
}
@ -195,6 +207,96 @@ public function test_download_pro_roundtrip_preserves_content(): void
}
}
public function test_export_ohne_service_context_enthaelt_keine_macros(): void
{
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Service Macro');
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song);
foreach ($this->allParserSlides($parserSong) as $slide) {
$this->assertFalse($slide->hasMacro());
}
}
public function test_export_mit_globaler_song_zuweisung_enthaelt_macro_auf_allen_slides(): void
{
$service = Service::factory()->create();
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Alle Folien Macro');
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
$this->assertNotEmpty($slides);
foreach ($slides as $slide) {
$this->assertTrue($slide->hasMacro());
$this->assertSame('Alle Folien Macro', $slide->getMacroName());
$this->assertSame($macro->uuid, $slide->getMacroUuid());
$this->assertSame('Export Collection', $slide->getMacroCollectionName());
}
}
public function test_export_mit_ausgeblendeter_macro_enthaelt_keine_macro(): void
{
$service = Service::factory()->create();
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Ausgeblendete Macro', ['hidden_at' => now()]);
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
foreach ($this->allParserSlides($parserSong) as $slide) {
$this->assertFalse($slide->hasMacro());
}
}
private function createMacroForExport(string $name, array $attributes = []): Macro
{
$macro = Macro::factory()->create(array_merge([
'uuid' => '11111111-2222-4333-8444-555555555555',
'name' => $name,
], $attributes));
$collection = MacroCollection::create([
'uuid' => '99999999-8888-4777-8666-555555555555',
'name' => 'Export Collection',
]);
$collection->macros()->attach($macro->id, ['order' => 0]);
return $macro;
}
private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array
{
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
private function assertStringContains(string $needle, ?string $haystack): void
{
$this->assertNotNull($haystack);

View file

@ -32,8 +32,10 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
$song = Song::where('title', 'Test')->first();
$this->assertNotNull($song);
$this->assertSame(4, $song->groups()->count());
$this->assertSame(5, $song->groups()->withCount('slides')->get()->sum('slides_count'));
$this->assertSame(4, \App\Models\Label::count());
$this->assertSame(5, \App\Models\SongSlide::count());
$this->assertSame(2, $song->arrangements()->count());
$this->assertTrue($song->has_translation);
}
@ -64,9 +66,18 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void
'title' => 'Old Title',
'ccli_id' => '999',
]);
$existingSong->groups()->create(['name' => 'Old Group', 'color' => '#FF0000', 'order' => 0]);
$this->assertSame(1, $existingSong->groups()->count());
$arrangement = $existingSong->arrangements()->create([
'name' => 'Normal',
'is_default' => true,
]);
$oldLabel = \App\Models\Label::firstOrCreate(['name' => 'Old Group'], ['color' => '#FF0000']);
$arrangement->arrangementLabels()->create([
'label_id' => $oldLabel->id,
'order' => 0,
]);
$this->assertSame(1, $arrangement->arrangementLabels()->count());
$existingSong->update(['ccli_id' => '999']);
$this->assertSame(1, Song::count());
@ -114,6 +125,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void
$this->assertNotNull($normalArrangement);
$this->assertTrue($normalArrangement->is_default);
$this->assertSame(5, $normalArrangement->arrangementGroups()->count());
$this->assertSame(5, $normalArrangement->arrangementLabels()->count());
}
}

View file

@ -0,0 +1,104 @@
<?php
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('override requires authentication', function () {
$service = Service::factory()->create();
$response = $this->post(route('services.macro-overrides.store', $service), ['part_type' => 'song']);
$response->assertRedirect(route('login'));
});
test('store creates override and snapshots global assignments', function () {
$user = User::factory()->create();
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'first_slide', 'order' => 1]);
$response = $this->actingAs($user)
->postJson(route('services.macro-overrides.store', $service), ['part_type' => 'song']);
$response->assertStatus(200)->assertJson(['success' => true]);
expect(ServiceMacroOverride::where('service_id', $service->id)->where('part_type', 'song')->exists())->toBeTrue();
expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(2);
});
test('store does not snapshot assignments from different part_type', function () {
$user = User::factory()->create();
$service = Service::factory()->create();
$macro = Macro::factory()->create();
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
$this->actingAs($user)
->postJson(route('services.macro-overrides.store', $service), ['part_type' => 'song']);
expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(1);
expect(ServiceMacroAssignment::where('service_id', $service->id)->first()->part_type)->toBe('song');
});
test('destroy removes override and service-specific assignments', function () {
$user = User::factory()->create();
$service = Service::factory()->create();
$macro = Macro::factory()->create();
ServiceMacroOverride::create(['service_id' => $service->id, 'part_type' => 'song']);
ServiceMacroAssignment::create([
'service_id' => $service->id,
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$response = $this->actingAs($user)
->deleteJson(route('services.macro-overrides.destroy', $service), ['part_type' => 'song']);
$response->assertStatus(200);
expect(ServiceMacroOverride::where('service_id', $service->id)->count())->toBe(0);
expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(0);
});
test('storeAssignment creates service-level assignment', function () {
$user = User::factory()->create();
$service = Service::factory()->create();
$macro = Macro::factory()->create();
$response = $this->actingAs($user)
->postJson(route('services.macro-assignments.store', $service), [
'part_type' => 'sermon',
'macro_id' => $macro->id,
'position' => 'last_slide',
'order' => 0,
]);
$response->assertStatus(200)->assertJson(['success' => true]);
expect(ServiceMacroAssignment::count())->toBe(1);
expect(ServiceMacroAssignment::first()->service_id)->toBe($service->id);
});
test('destroyAssignment removes service-level assignment', function () {
$user = User::factory()->create();
$service = Service::factory()->create();
$macro = Macro::factory()->create();
$assignment = ServiceMacroAssignment::create([
'service_id' => $service->id,
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'all_slides',
'order' => 0,
]);
$response = $this->actingAs($user)
->deleteJson(route('services.macro-assignments.destroy', [$service, $assignment]));
$response->assertStatus(200);
expect(ServiceMacroAssignment::count())->toBe(0);
});

View file

@ -1,25 +1,17 @@
<?php
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song CRUD API Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
// --- INDEX / LIST ---
test('songs index returns paginated list', function () {
Song::factory()->count(3)->create();
@ -75,8 +67,6 @@
$response->assertUnauthorized();
});
// --- STORE / CREATE ---
test('store creates song with default groups and arrangement', function () {
$response = $this->actingAs($this->user)
->postJson('/api/songs', [
@ -91,16 +81,12 @@
$song = Song::where('title', 'Neues Lied')->first();
expect($song)->not->toBeNull();
// Default groups: Strophe 1, Refrain, Bridge
expect($song->groups)->toHaveCount(3);
expect($song->groups->pluck('name')->toArray())
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
// Default "Normal" arrangement
$arrangement = $song->arrangements()->where('is_default', true)->first();
expect($arrangement)->not->toBeNull();
expect($arrangement->name)->toBe('Normal');
expect($arrangement->arrangementGroups)->toHaveCount(3);
expect($arrangement->arrangementLabels)->toHaveCount(3);
expect($arrangement->arrangementLabels->sortBy('order')->pluck('label.name')->toArray())
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
});
test('store validates required title', function () {
@ -136,12 +122,15 @@
expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull();
});
// --- SHOW ---
test('show returns song with groups slides and arrangements', function () {
$song = Song::factory()->create();
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']);
SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
$label = Label::factory()->create(['name' => 'Strophe 1']);
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => 1,
]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
@ -174,8 +163,6 @@
$response->assertNotFound();
});
// --- UPDATE ---
test('update modifies song metadata', function () {
$song = Song::factory()->create(['title' => 'Old Title']);
@ -197,7 +184,6 @@
$songA = Song::factory()->create(['ccli_id' => '111111']);
$songB = Song::factory()->create(['ccli_id' => '222222']);
// Try setting songB's ccli_id to songA's
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$songB->id}", [
'title' => $songB->title,
@ -220,8 +206,6 @@
$response->assertOk();
});
// --- DESTROY / SOFT DELETE ---
test('destroy soft-deletes a song', function () {
$song = Song::factory()->create();
@ -242,8 +226,6 @@
$response->assertNotFound();
});
// --- LAST USED IN SERVICE ---
test('last_used_in_service returns correct date from service_songs', function () {
$song = Song::factory()->create();
$serviceOld = Service::factory()->create(['date' => '2025-06-01']);
@ -275,26 +257,24 @@
expect($response->json('data.last_used_in_service'))->toBeNull();
});
// --- SONG SERVICE: DUPLICATE ARRANGEMENT ---
test('duplicate arrangement clones arrangement with groups', function () {
$song = Song::factory()->create();
$group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]);
$group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]);
$label1 = Label::factory()->create();
$label2 = Label::factory()->create();
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Original',
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id,
'label_id' => $label1->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id,
'label_id' => $label2->id,
'order' => 2,
]);
@ -303,7 +283,7 @@
expect($clone->name)->toBe('Klone');
expect($clone->is_default)->toBeFalse();
expect($clone->arrangementGroups)->toHaveCount(2);
expect($clone->arrangementGroups->pluck('song_group_id')->toArray())
->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray());
expect($clone->arrangementLabels)->toHaveCount(2);
expect($clone->arrangementLabels->pluck('label_id')->toArray())
->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray());
});

View file

@ -1,28 +1,15 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song Edit Modal Tests
|--------------------------------------------------------------------------
|
| Tests verifying the API endpoints used by SongEditModal.vue:
| - GET /api/songs/{id} (show with groups + arrangements)
| - PUT /api/songs/{id} (auto-save metadata)
|
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
// --- Modal Data Loading ---
test('show returns song with full detail for modal', function () {
$song = Song::factory()->create([
'title' => 'Großer Gott wir loben Dich',
@ -30,18 +17,14 @@
'copyright_text' => '© Public Domain',
]);
$group1 = SongGroup::factory()->create([
'song_id' => $song->id,
$label1 = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
'order' => 1,
]);
$group2 = SongGroup::factory()->create([
'song_id' => $song->id,
$label2 = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
'order' => 2,
]);
$arrangement = SongArrangement::factory()->create([
@ -50,15 +33,15 @@
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id,
'label_id' => $label1->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id,
'label_id' => $label2->id,
'order' => 2,
]);
@ -77,17 +60,13 @@
],
]);
// Groups in correct order
expect($response->json('data.groups.0.name'))->toBe('Strophe 1');
expect($response->json('data.groups.1.name'))->toBe('Refrain');
// Arrangement with arrangement_groups
expect($response->json('data.arrangements.0.name'))->toBe('Normal');
expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2);
});
// --- Metadata Auto-Save ---
test('update saves title via auto-save', function () {
$song = Song::factory()->create(['title' => 'Original Title']);
@ -155,7 +134,6 @@
test('update returns full song detail with arrangements', function () {
$song = Song::factory()->create();
SongGroup::factory()->create(['song_id' => $song->id]);
SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]);
$response = $this->actingAs($this->user)

View file

@ -1,18 +1,12 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song PDF Download Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
@ -24,22 +18,20 @@
'name' => 'Normal',
]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Verse 1',
'color' => '#3B82F6',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Amazing grace how sweet the sound',
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);
@ -73,42 +65,37 @@
'name' => 'Normal',
]);
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
$verse = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
$chorus = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
'order' => 2,
]);
SongSlide::factory()->create([
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
'text_content' => 'Großer Gott wir loben dich',
]);
SongSlide::factory()->create([
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 1,
'text_content' => 'Heilig heilig heilig',
]);
// Arrangement: Strophe 1 -> Refrain
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 2,
]);
@ -130,22 +117,20 @@
'name' => 'Normal',
]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Verse 1',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Amazing grace how sweet the sound',
'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang',
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);
@ -210,21 +195,19 @@
'name' => 'Übung',
]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Strophe 1',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Großer Gott wir loben dich',
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);
@ -234,7 +217,6 @@
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
// Filename should contain slug with umlauts handled
$contentDisposition = $response->headers->get('Content-Disposition');
expect($contentDisposition)->toContain('.pdf');
});
@ -260,28 +242,24 @@
'ccli_id' => '123456',
]);
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
$verse = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3b82f6',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
$chorus = Label::factory()->create([
'name' => 'Refrain',
'color' => '#ef4444',
'order' => 2,
]);
SongSlide::factory()->create([
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
'text_content' => 'Strophe Text',
]);
SongSlide::factory()->create([
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 1,
'text_content' => 'Refrain Text',
]);
@ -291,16 +269,15 @@
'name' => 'Normal',
]);
// Order: Chorus first, then Verse
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 2,
]);
@ -321,7 +298,6 @@
],
]);
// Chorus should be first (order=1), Verse second (order=2)
$data = $response->json();
expect($data['groups'][0]['name'])->toBe('Refrain');
expect($data['groups'][1]['name'])->toBe('Strophe 1');
@ -331,14 +307,12 @@
test('song preview includes translation text when slides have translations', function () {
$song = Song::factory()->create(['title' => 'Lied mit Übersetzung']);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Verse',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Original Text',
'text_content_translated' => 'Translated Text',
@ -348,9 +322,9 @@
'song_id' => $song->id,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);

View file

@ -2,12 +2,12 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
@ -79,18 +79,14 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'has_translation' => true,
]);
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
$verse = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
$chorus = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
'order' => 2,
]);
$normal = SongArrangement::factory()->create([
@ -99,15 +95,15 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 2,
]);

View file

@ -3,8 +3,10 @@
namespace Tests\Feature;
use App\Http\Controllers\TranslationController;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\JsonResponse;
@ -21,36 +23,50 @@ public function test_translate_page_response_contains_ordered_groups_and_slides(
'title' => 'Grosser Gott',
]);
$groupLater = SongGroup::factory()->create([
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain',
'color' => '#22c55e',
'order' => 2,
'name' => 'Normal',
'is_default' => true,
]);
$groupFirst = SongGroup::factory()->create([
'song_id' => $song->id,
$labelLater = Label::factory()->create([
'name' => 'Refrain',
'color' => '#22c55e',
]);
$labelFirst = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#0ea5e9',
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelFirst->id,
'order' => 1,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelLater->id,
'order' => 2,
]);
SongSlide::factory()->create([
'song_group_id' => $groupFirst->id,
'label_id' => $labelFirst->id,
'order' => 2,
'text_content' => "Zeile A\nZeile B",
'text_content_translated' => "Line A\nLine B",
]);
SongSlide::factory()->create([
'song_group_id' => $groupFirst->id,
'label_id' => $labelFirst->id,
'order' => 1,
'text_content' => "Zeile C\nZeile D\nZeile E",
'text_content_translated' => null,
]);
SongSlide::factory()->create([
'song_group_id' => $groupLater->id,
'label_id' => $labelLater->id,
'order' => 1,
'text_content' => 'Refrain',
'text_content_translated' => 'Chorus',

View file

@ -1,25 +1,19 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\User;
use App\Services\TranslationService;
use Illuminate\Support\Facades\Http;
/*
|--------------------------------------------------------------------------
| Translation Service Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
$this->service = app(TranslationService::class);
});
// --- URL FETCH ---
test('fetchFromUrl returns text from successful HTTP response', function () {
Http::fake([
'https://example.com/lyrics' => Http::response('<html><body><p>Zeile 1</p><p>Zeile 2</p></body></html>', 200),
@ -30,7 +24,6 @@
expect($result)->not->toBeNull();
expect($result)->toContain('Zeile 1');
expect($result)->toContain('Zeile 2');
// HTML tags should be stripped
expect($result)->not->toContain('<p>');
expect($result)->not->toContain('<html>');
});
@ -65,34 +58,56 @@
expect($result)->toBeNull();
});
// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) ---
test('importTranslation distributes lines by slide line counts', function () {
function makeSongWithDefaultArrangement(): array
{
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1',
'order' => 1,
'name' => 'Normal',
'is_default' => true,
]);
// Slide 1: 4 lines
return [$song, $arrangement];
}
function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): Label
{
$label = Label::firstOrCreate(['name' => $labelName]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => $arrangementOrder,
]);
foreach ($slides as $slide) {
SongSlide::factory()->create(array_merge(
['label_id' => $label->id],
$slide,
));
}
return $label;
}
test('importTranslation distributes lines by slide line counts', function () {
[$song, $arrangement] = makeSongWithDefaultArrangement();
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
]);
// Slide 2: 2 lines
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 2,
'text_content' => "Original 5\nOriginal 6",
]);
// Slide 3: 4 lines
$slide3 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 3,
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
]);
@ -105,37 +120,25 @@
$slide2->refresh();
$slide3->refresh();
// Slide 1 gets lines 1-4
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4");
// Slide 2 gets lines 5-6
expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6");
// Slide 3 gets lines 7-10
expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10");
});
test('importTranslation distributes across multiple groups', function () {
$song = Song::factory()->create(['has_translation' => false]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$group1 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1',
'order' => 1,
]);
$group2 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain',
'order' => 2,
]);
$label1 = attachLabelWithSlides($arrangement, 'Strophe 1 - multi', [], 1);
$label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group1->id,
'label_id' => $label1->id,
'order' => 1,
'text_content' => "Line A\nLine B",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group2->id,
'label_id' => $label2->id,
'order' => 1,
'text_content' => "Line C\nLine D\nLine E",
]);
@ -152,26 +155,22 @@
});
test('importTranslation handles fewer translation lines than original', function () {
$song = Song::factory()->create(['has_translation' => false]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => "Line 1\nLine 2\nLine 3",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 2,
'text_content' => "Line 4\nLine 5",
]);
// Only 3 lines for 5 lines total
$translatedText = "Zeile 1\nZeile 2\nZeile 3";
$this->service->importTranslation($song, $translatedText);
@ -179,22 +178,16 @@
$slide1->refresh();
$slide2->refresh();
// Slide 1 gets all 3 available lines
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3");
// Slide 2 gets empty (no lines left)
expect($slide2->text_content_translated)->toBe('');
});
test('importTranslation marks song as translated', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Line 1',
]);
@ -205,8 +198,6 @@
expect($song->has_translation)->toBeTrue();
});
// --- MARK AS TRANSLATED ---
test('markAsTranslated sets has_translation to true', function () {
$song = Song::factory()->create(['has_translation' => false]);
@ -216,25 +207,21 @@
expect($song->has_translation)->toBeTrue();
});
// --- REMOVE TRANSLATION ---
test('removeTranslation clears all translated text and sets flag to false', function () {
$song = Song::factory()->create(['has_translation' => true]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$song->update(['has_translation' => true]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Original',
'text_content_translated' => 'Übersetzt',
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 2,
'text_content' => 'Original 2',
'text_content_translated' => 'Übersetzt 2',
@ -251,8 +238,6 @@
expect($slide2->text_content_translated)->toBeNull();
});
// --- CONTROLLER ENDPOINTS ---
test('POST translation/fetch-url returns scraped text', function () {
Http::fake([
'https://lyrics.example.com/song' => Http::response('<div>Liedtext Zeile 1</div>', 200),
@ -292,15 +277,12 @@
});
test('POST songs/{song}/translation/import distributes and saves translation', function () {
$song = Song::factory()->create(['has_translation' => false]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1);
$slide = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => "Line 1\nLine 2",
]);
@ -340,15 +322,13 @@
});
test('DELETE songs/{song}/translation removes translation', function () {
$song = Song::factory()->create(['has_translation' => true]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$song->update(['has_translation' => true]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Original',
'text_content_translated' => 'Übersetzt',

View file

@ -0,0 +1,27 @@
<?php
use App\Support\MacroColorConverter;
test('converts rgba floats to hex string', function () {
expect(MacroColorConverter::fromRgba(['r' => 1.0, 'g' => 0.0, 'b' => 0.5, 'a' => 1.0]))->toBe('#FF0080');
});
test('converts pure black', function () {
expect(MacroColorConverter::fromRgba(['r' => 0.0, 'g' => 0.0, 'b' => 0.0, 'a' => 1.0]))->toBe('#000000');
});
test('converts pure white', function () {
expect(MacroColorConverter::fromRgba(['r' => 1.0, 'g' => 1.0, 'b' => 1.0, 'a' => 1.0]))->toBe('#FFFFFF');
});
test('converts grey', function () {
expect(MacroColorConverter::fromRgba(['r' => 0.5, 'g' => 0.5, 'b' => 0.5, 'a' => 1.0]))->toBe('#808080');
});
test('clamps out-of-range values', function () {
expect(MacroColorConverter::fromRgba(['r' => 1.5, 'g' => -0.1, 'b' => 0.5, 'a' => 0.0]))->toBe('#FF0080');
});
test('returns null for null input', function () {
expect(MacroColorConverter::fromRgba(null))->toBeNull();
});

View file

@ -0,0 +1,40 @@
<?php
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
use Rv\Data\Action\Label;
use Rv\Data\Color;
use Rv\Data\ProLabelsDocument;
function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
{
$color = new Color;
$color->setRed($r);
$color->setGreen($g);
$color->setBlue($b);
$color->setAlpha($a);
return $color;
}
$doc = new ProLabelsDocument;
$labels = [
['name' => 'Copyright', 'r' => 0.8, 'g' => 0.2, 'b' => 0.2],
['name' => 'Vers 1', 'r' => 0.2, 'g' => 0.6, 'b' => 0.9],
['name' => 'Refrain', 'r' => 0.9, 'g' => 0.7, 'b' => 0.1],
['name' => 'Brücke', 'r' => 0.5, 'g' => 0.1, 'b' => 0.8],
];
$labelObjects = [];
foreach ($labels as $data) {
$label = new Label;
$label->setText($data['name']);
$label->setColor(makeColor($data['r'], $data['g'], $data['b']));
$labelObjects[] = $label;
}
$doc->setLabels($labelObjects);
$output = $doc->serializeToString();
file_put_contents(__DIR__.'/labels-sample.bin', $output);
echo 'Written '.strlen($output).' bytes'.PHP_EOL;

View file

@ -0,0 +1,66 @@
<?php
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
use Rv\Data\Color;
use Rv\Data\MacrosDocument;
use Rv\Data\MacrosDocument\Macro;
use Rv\Data\MacrosDocument\MacroCollection;
use Rv\Data\MacrosDocument\MacroCollection\Item;
use Rv\Data\UUID;
function makeUuid(string $value): UUID
{
$uuid = new UUID;
$uuid->setString(strtoupper($value));
return $uuid;
}
function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
{
$color = new Color;
$color->setRed($r);
$color->setGreen($g);
$color->setBlue($b);
$color->setAlpha($a);
return $color;
}
$doc = new MacrosDocument;
$macros = [
['uuid' => 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Copyright Makro', 'r' => 1.0, 'g' => 0.5, 'b' => 0.0],
['uuid' => 'BBBBBBBB-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Vers Makro', 'r' => 0.0, 'g' => 0.8, 'b' => 0.2],
['uuid' => 'CCCCCCCC-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Refrain Makro', 'r' => 0.2, 'g' => 0.4, 'b' => 1.0],
];
$macroObjects = [];
foreach ($macros as $data) {
$macro = new Macro;
$macro->setUuid(makeUuid($data['uuid']));
$macro->setName($data['name']);
$macro->setColor(makeColor($data['r'], $data['g'], $data['b']));
$macro->setTriggerOnStartup(false);
$macro->setImageType(0);
$macroObjects[] = $macro;
}
$doc->setMacros($macroObjects);
$collection = new MacroCollection;
$collection->setUuid(makeUuid('8D02FC57-83F8-4042-9B90-81C229728426'));
$collection->setName('--MAIN--');
$items = [];
foreach ($macros as $data) {
$item = new Item;
$item->setMacroId(makeUuid($data['uuid']));
$items[] = $item;
}
$collection->setItems($items);
$doc->setMacroCollections([$collection]);
$output = $doc->serializeToString();
file_put_contents(__DIR__.'/macros-sample.bin', $output);
echo 'Written '.strlen($output).' bytes'.PHP_EOL;

BIN
tests/fixtures/labels-sample.bin vendored Normal file

Binary file not shown.

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