Compare commits

..

No commits in common. "master" and "dev-with-ddev" have entirely different histories.

101 changed files with 920 additions and 4263 deletions

View file

@ -1,9 +0,0 @@
php artisan test tests/Feature/Migrations/LabelsTableTest.php
PASS Tests\Feature\Migrations\LabelsTableTest
✓ labels table has expected columns 0.40s
✓ labels table enforces unique name 0.01s
✓ labels table allows nullable color 0.01s
Tests: 3 passed (4 assertions)
Duration: 0.54s

View file

@ -1,33 +0,0 @@
migrate:fresh output
Dropping all tables ........................................... 13.01ms DONE
INFO Preparing database.
Creating migration table ....................................... 4.76ms DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ........................... 9.49ms DONE
0001_01_01_000001_create_cache_table ........................... 4.35ms DONE
0001_01_01_000002_create_jobs_table ............................ 4.88ms DONE
2026_03_01_100000_extend_users_table ........................... 4.60ms DONE
2026_03_01_100100_create_services_table ........................ 2.92ms DONE
2026_03_01_100200_create_songs_table ........................... 2.08ms DONE
2026_03_01_100300_create_song_groups_table ..................... 3.09ms DONE
2026_03_01_100400_create_song_slides_table ..................... 5.10ms DONE
2026_03_01_100500_create_song_arrangements_table ............... 3.19ms DONE
2026_03_01_100600_create_song_arrangement_groups_table ......... 3.61ms DONE
2026_03_01_100700_create_service_songs_table ................... 3.25ms DONE
2026_03_01_100800_create_slides_table .......................... 3.68ms DONE
2026_03_01_100900_create_cts_sync_log_table .................... 4.32ms DONE
2026_03_02_100000_create_api_request_logs_table ................ 2.15ms DONE
2026_03_02_121522_add_response_body_to_api_request_logs_table .. 1.31ms DONE
2026_03_02_130249_add_cts_song_id_to_songs_and_service_songs_tables 3.30ms DONE
2026_03_02_140000_add_sort_order_to_slides_table ............... 0.91ms DONE
2026_03_02_200000_create_settings_table ........................ 2.48ms DONE
2026_03_29_100001_create_service_agenda_items_table ............ 3.03ms DONE
2026_03_29_100002_add_service_agenda_item_id_to_slides_table .. 13.03ms DONE
2026_03_29_131045_add_missing_cts_song_id_to_service_songs_table 0.53ms DONE
2026_03_29_131359_add_has_agenda_to_services_table ............. 1.24ms DONE
2026_05_03_100100_create_labels_table .......................... 2.50ms DONE

View file

@ -14,7 +14,6 @@
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware. - 2026-03-01: 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: `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-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 ## [2026-03-01] Wave 2 Complete — T8-T13
@ -351,5 +350,3 @@ ### Verification Success Criteria Met
### Next Steps ### Next Steps
- Task 2 will likely involve testing OAuth login flow with ChurchTools - Task 2 will likely involve testing OAuth login flow with ChurchTools
- May need to configure CTS_API_TOKEN and CHURCHTOOLS credentials for full testing - 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

@ -1,41 +0,0 @@
# Decisions — macros-and-labels-import
## [2026-05-03] Architectural Decisions
### Schema
- **labels table**: global, unique by name, nullable color, hidden_at (NOT deleted_at)
- **macros table**: unique by uuid (uppercase), hidden_at (NOT deleted_at)
- **macro_assignments**: restrictOnDelete on macro_id and label_id FKs
- **service_macro_overrides**: existence of row = override active; no extra boolean
- **song_arrangement_labels**: replaces song_arrangement_groups; references global label_id
### Macro Assignment Semantics
- `part_type` enum: `information | moderation | sermon | song | agenda_item`
- `position` enum: `all_slides | first_slide | last_slide | by_label`
- `by_label` is valid for ALL part_types (not songs-only) — validated at app level if restriction needed
- Stacking: multiple assignments can fire on same slide — all applied in `order ASC`
- Override wins 100% — no globals bleed through when override exists
### Override Semantics
- "Anpassen" snapshots current globals into `service_macro_assignments` rows
- "Auf Standard zurücksetzen" deletes the override row + cascades service_macro_assignments
- German tooltip: "Erstellt eine Kopie der aktuellen globalen Zuweisungen für diesen Gottesdienst. Spätere Änderungen an den globalen Zuweisungen wirken sich auf diesen Gottesdienst NICHT mehr aus."
### Data Migration
- Destructive: `up()` deletes songs, song_groups, song_slides, song_arrangements, song_arrangement_groups
- `down()` throws RuntimeException (irreversible)
- Guard: `if (!Schema::hasTable('song_groups') || !DB::table('song_groups')->exists()) return;`
- Old 4 macro settings keys → migrated to global assignment if all present; then deleted
### Label Color Priority
1. Labels file import → always sets/overwrites color
2. .pro song import → only sets color on CREATE (new label); existing color preserved
3. UI → read-only (no manual edit)
### Current Migration Scope
- `labels` migration only defines the schema; no model or business logic belongs in this task
- Use `hidden_at` instead of `deleted_at` to align with soft-hide semantics
### Macros Tables Task
- Keep all three tables in one migration file so the schema lands together and the junction FKs resolve cleanly during `migrate:fresh`
- Store `last_imported_filename` as nullable text metadata on `macros`; no separate import log table for this task

View file

@ -1,145 +0,0 @@
# Learnings — macros-and-labels-import
## [2026-05-03] Session ses_210cd1557ffeGs4SEGrt7hnvyS — Plan Created
### Parser Library
- Source at `/Users/thorsten/AI/propresenter/src/` (NOT `/Users/thorsten/AI/propresenter-work/php/` per stale AGENTS.md)
- VCS repo: `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (dev-master)
- New classes (NOT yet in vendor/): `MacrosFileReader`, `LabelsFileReader`, `Macro`, `MacroLibrary`, `MacroCollection`, `Label`, `LabelLibrary`
- `MacrosFileReader::read(string $filePath): MacroLibrary` — raw protobuf binary, no extension
- `LabelsFileReader::read(string $filePath): LabelLibrary` — same
- `Label::getName()` returns protobuf `text` field — name is the identity (no UUID for labels)
- `Macro::getColor()` returns `?array{r,g,b,a}` floats 0..1 — need `MacroColorConverter` to get hex
- `Label::getColorHex()` already returns `#RRGGBB` — mirror its formula for macros
- **PHP 8.4 required** by parser. App currently requires `^8.2` — BLOCKER for T0.1
### DB Schema Key Facts
- `slides.type` enum is `[information, moderation, sermon]` ONLY — no `agenda_item`
- `agenda_item` part_type = slide where `service_agenda_item_id IS NOT NULL` at runtime
- `song_groups.color` is NOT NULLABLE (migration says so) — new `labels.color` IS nullable
- `service_songs.song_id` is `cascadeOnDelete` — wiping `songs` auto-cascades to `service_songs`
### Export Flow
- `ProExportService::buildGroups()` lines 38-69 — macro injection point
- `ProExportService::buildMacroData()` lines 71-86 — reads 4 legacy settings keys
- Currently injects macro ONLY when group name is "COPYRIGHT" (case-insensitive)
- `ProImportService::import(UploadedFile $file): array` — method signature (NOT `importFromFile`)
### Settings Pattern
- `Setting::get($key, $default)` / `Setting::set($key, $value)` — simple key/value
- `settings` table: `key UNIQUE, value TEXT`
### Critical Decisions
- song_groups → labels: global table, "drop all data" migration (no backwards compat)
- Hybrid macro scope: global defaults in Settings; per-(service, part_type) override via "Anpassen"
- Override = snapshot of globals at creation time; future global changes don't propagate
- Stacking: all matching assignments fire, ordered by `macro_assignments.order ASC`
- Hidden macros/labels: skip at export, warning badge in editor
- Label colors: read-only in UI; Labels file import is sole authority; .pro auto-discovery only sets color on CREATE
- FK rules: `restrictOnDelete` on macro/label refs (use `hidden_at`); `cascadeOnDelete` on service-scoped rows
### Migration/Test Notes
- `tests/Pest.php` already applies `RefreshDatabase` to all `Feature` tests; no extra setup needed for `Feature/Migrations`
- SQLite unique constraint errors can be asserted with `->toThrow(\Exception::class)` in migration tests
- `macro_collection_macros` can safely use a reserved-ish `order` column name in SQLite/Laravel migrations; the schema + foreign keys passed `migrate:fresh`
- `foreignId()->constrained()->cascadeOnDelete()` correctly cascades through the junction table under the current sqlite test setup
## T2.1 — Models + Factories (label-based schema)
- **All new model conventions match house style**: `$fillable` array, `casts()` method (not `$casts` property), typed return types on relations.
- **`hidden_at` semantics, NOT SoftDeletes**: `Label` and `Macro` use `hidden_at` timestamp + `isHidden()` helper; SoftDeletes deliberately not used.
- **`MacroCollection` pivot ordering**: `belongsToMany(Macro::class, 'macro_collection_macros')->withPivot('order')->orderBy('macro_collection_macros.order')` — must qualify the column with the pivot table name to avoid SQLite ambiguous column errors.
- **`ServiceMacroOverride::assignments()` uses composite-key relation**: HasMany on `service_id` with explicit `where('part_type', $this->part_type)` filter (Eloquent has no native composite-FK support).
- **`SongArrangement::arrangementLabels()` ordered**: `hasMany(SongArrangementLabel::class)->orderBy('order')` so consumers see labels in the correct slide order without re-sorting.
- **`SongArrangementLabelFactory`** uses `Label::factory()` and `SongArrangement::factory()` directly — both have HasFactory trait.
- **Test gating**: After T2.1 alone, 270/328 tests pass. The remaining 58 failures are all in `app/Services/SongService.php`, `app/Services/ProImportService.php`, and the test files that exercise those services; T4.4 owns those updates.
- **`DatabaseSchemaTest`** passes cleanly (3 tests / 31 assertions): all expected tables exist, dropped tables gone, all factories produce valid rows.
## T4.4 — PHP rename audit (2026-05-03)
After Wave 2's schema migration (`song_groups` → `labels`, `song_arrangement_groups``song_arrangement_labels`), the rename-audit cleanup turned out to span **far more files** than the plan listed (12 app files + 11 test files vs 7 listed). Key findings:
- `Song::groups()` relation was completely removed; many call sites needed adaptation, not just rename. New pattern: traverse `Song -> arrangements -> arrangementLabels -> label -> songSlides` for content.
- `song_slides` table only has `label_id` (no `song_id` either) — slides are now globally owned by labels. Tests that previously did `$verse = $song->groups()->create(...)` need to find/create a global Label and link it via `SongArrangementLabel`.
- Helper functions defined at file level in Pest tests work cleanly: `function makeSongWithDefaultArrangement(): array { ... }` keeps test setup DRY.
- Fixture `Test.pro` has 4 groups but only 3 are referenced in any arrangement — assertion needs to count `Label::count()` (post-import) to verify "all 4 groups created", not arrangement labels.
- `MacroColorConverter::fromRgba()` (assoc-keyed `r,g,b`) replaces the old `ProImportService::rgbaToHex()` for label color conversion in importer; the legacy hex helpers were preserved because `ProFileGenerator::colorFromArray` uses numeric-indexed RGBA.
- Removing the "groups must belong to this song" check in `ArrangementController::update` is correct since labels are global; `exists:labels,id` validation is sufficient.
## Wave 2 — T2.3, T2.4, T2.5 (services)
### LabelsImportService
- Case-insensitive name lookup via `whereRaw('LOWER(name) = ?', [strtolower($name)])`
- Always updates color on existing labels (additive policy, never disables)
- Skips labels with empty names
- Stores metadata in `settings` table: `labels_last_imported_at`, `labels_last_imported_filename`
### MacrosImportService
- UUID is normalized to UPPER before storage (matches parser convention)
- Macros not in file get `hidden_at = now()` (soft-disable, not delete)
- Re-import re-enables a previously hidden macro by setting `hidden_at = null`
- Tracks `wasHidden` to differentiate `reEnabled` vs `updated` counts
- Collection sync: detach all → attach with order index from parser
- Warnings: any MacroAssignment whose macro is currently hidden
### MacroResolutionService
- Override-vs-defaults: `ServiceMacroOverride` existence check decides whether to use service-specific or global assignments
- Hidden macros and hidden labels (for `by_label`) are filtered via Collection->reject()
- `macrosForSlide` uses match() expression for position semantics
- Default collection fallback: `--MAIN--` with UUID `8D02FC57-83F8-4042-9B90-81C229728426`
### Pint quirk
- DTO classes with empty body need `{}` on same line as constructor closing paren — `single_line_empty_body` rule.
### Test patterns
- Pest auto-applies `RefreshDatabase` via `tests/Pest.php` for all Feature tests, but explicit `uses(RefreshDatabase::class)` is harmless and matches spec.
- All 354 tests pass (was 334 before Wave 2.3-2.5).
## T2.7 ProExportService MacroResolutionService
- ProPresenter parser package currently consumes only `$slideData['macro']` in `ProFileGenerator::buildCue()`; no `$slideData['macros']` stacking support exists. `Slide::setMacro()` also updates/replaces the first macro action.
- `ProExportService` now keeps song downloads backward-compatible by accepting optional `?Service`; exports without service context intentionally emit no macros.
- Playlist/bundle service exports must pass the active `Service` into `generateProFile()` / `generateParserSong()` so `MacroResolutionService::macrosForSlide()` can resolve global or service-specific assignments.
- Full verification for T2.7: `ddev exec php artisan test` passed with 357 tests / 1706 assertions; evidence in `.sisyphus/evidence/task-2.7-pest.txt`.
## T2.8 Controllers + Routes (2026-05-03)
- **4 thin controllers, all JSON responses for mutations** (Inertia only on `MacroAssignmentController::index`).
- **Validation via inline `$request->validate()`** with `in:` lists for `part_type` (information, moderation, sermon, song, agenda_item) and `position` (all_slides, first_slide, last_slide, by_label).
- **Route ordering matters**: `/settings/macro-assignments/reorder` MUST be registered BEFORE `/settings/macro-assignments/{macroAssignment}`, else `reorder` is captured as the model parameter.
- **Route-model binding works automatically** for both `{macroAssignment}` and `{serviceMacroAssignment}` — Laravel resolves snake_case → StudlyCase → Eloquent model.
- **Unused `$service` parameter on update/destroyAssignment** is intentional: route-model binding requires it in the signature even if the assignment binding alone does the work.
- **Generic 422 message** for parser failures hides internal exception details from users; all messages German Du-form.
- **Test fixtures `tests/fixtures/macros-sample.bin` & `labels-sample.bin`** work with `new UploadedFile(path, name, null, null, true)` (5th arg `$test=true` keeps the file at original path so `getPathname()` returns the fixture).
- **`UploadedFile::fake()->create('x.bin', 1)`** generates a 1KB empty file that fails parser parsing → triggers the controller's catch block → 422 JSON.
- **Auth tests use plain `post()` (form-data) → `assertRedirect(route('login'))`**; JSON requests would return 401, but session-based auth redirects.
- **Final test count: 376 (was 357) → +19 new tests / +54 assertions.**
## T4.2: Service Edit Macro Panel
- `ServiceController::edit()` now passes `macros_per_part` keyed by part_type (information, moderation, sermon, song, agenda_item).
- Each entry: `count`, `is_overridden`, `has_warning`, `assignments[]` (with macro_id/name/color/hidden, position, label_id/name).
- Uses `MacroResolutionService::resolveAssignmentsForPart()` (already filters hidden macros + by_label with hidden labels). `has_warning` checks raw flags before resolver filters them — but since resolver already filters, `has_warning` will normally be false. Acceptable for badge UI.
- `ServiceMacroOverride::where(...)->exists()` checks override status per part.
- `ServicePartMacroPanel.vue` is positioned `absolute right-0 top-8 z-50` — wrapper must be `class="relative"`.
- Edit.vue page only has 2 visible block headers (Ablauf and Information). Placed agenda_item/moderation/sermon/song MacroIcons in the Ablauf header row; placed information MacroIcon in the Information block header.
- MacroIcon renders only when `count > 0`, so empty parts gracefully hide their badge.
- Routes used: `services.macro-overrides.store` (POST + body `{part_type}`), `services.macro-overrides.destroy` (DELETE + body). XSRF token sourced from `XSRF-TOKEN` cookie (URL-decoded).
## Final Verification F4 (2026-05-04)
- Scope-fidelity verification passed: `Label`/`Macro` use `hidden_at` (no SoftDeletes), label imports are additive with color overwrite, missing macros are hidden via `hidden_at`, `MacroResolutionService` resolves override/default assignments and filters hidden macros/labels, `ProExportService` injects `MacroResolutionService` with no legacy `buildMacroData()`, and `SettingsController` only exposes the four `AGENDA_KEYS`.
- Forbidden-pattern grep suite returned no output for label CRUD, macro action runner/editor patterns, TS suppressions, Vue console logs, bulk operations, label/macro drag reorder, and export caching.
## [2026-05-04] Session follow-up — hidden label badge + nullable import color
- `MacroAssignments.vue` should mirror hidden-macro warnings for `by_label` rows with `a.label?.hidden_at`, using a red badge and `data-testid="warning-hidden-label"`.
- `ProImportService` must keep new label colors nullable: `MacroColorConverter::fromRgba($color)` should flow through unchanged so missing `.pro` colors become `NULL`, not `#808080`.
## 2026-05-04 F1 final compliance audit
- Final verification commands passed: no Vue song_group_id references, MacroIcon and hidden-label test IDs present, ProImportService #808080 fallback removed, required macro/label deliverables and schema present, Label/Macro use hidden_at without SoftDeletes, routes present, ProBundleExportService resolves macros for information/moderation/sermon and agenda_item exports.
- MacroResolutionService supports part types dynamically via part_type string; grep for literal part names can be empty without indicating non-support.
## 2026-05-04 F4 scope fidelity check
- Must-NOT grep suite found one historical toast pattern in pre-existing service/song Vue files; no macro/label feature-specific forbidden patterns were found (no SoftDeletes/deleted_at, drag UI, runner/preview, bulk ops, optimistic markers, collection assignment, export caching, agenda_item slide enum, TS suppressions, or console.log).
- Required macro/label evidence present: all 5 part types, position enum including by_label, hidden_at semantics, explicit restrict/cascade FKs, stacking resolver (`filter` → `map``values``all`), bundle export macro injection, German UI labels, and test IDs on macro/label picker/icon components.
- Unaccounted grep output for `ArrangementConfigurator.vue`, `ArrangementDialog.vue`, and `SongEditModal.vue` is explained by planned SongGroup → Label rename work, not unrelated scope creep.

View file

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

View file

@ -1,37 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\LabelsImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class LabelImportController extends Controller
{
public function __construct(
private readonly LabelsImportService $importService,
) {}
public function store(Request $request): JsonResponse
{
$request->validate(['file' => ['required', 'file', 'max:5120']]);
$file = $request->file('file');
$tempPath = $file->getPathname();
try {
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
} catch (Throwable $e) {
return response()->json([
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Labels-Datei ist.',
], 422);
}
return response()->json([
'new' => $result->newCount,
'updated' => $result->updatedCount,
'total' => $result->totalInFile,
]);
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MacroAssignmentController extends Controller
{
public function index(): Response
{
return Inertia::render('Settings', [
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
'macros' => Macro::with('collections')->orderBy('name')->get(),
'labels' => Label::orderBy('name')->get(),
'collections' => MacroCollection::orderBy('name')->get(),
'last_macros_import' => [
'at' => Setting::get('macros_last_imported_at'),
'filename' => Setting::get('macros_last_imported_filename'),
],
'last_labels_import' => [
'at' => Setting::get('labels_last_imported_at'),
'filename' => Setting::get('labels_last_imported_filename'),
],
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
'macro_id' => ['required', 'integer', 'exists:macros,id'],
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['integer', 'min:0'],
]);
$assignment = MacroAssignment::create($validated);
return response()->json(['id' => $assignment->id, 'success' => true]);
}
public function update(Request $request, MacroAssignment $macroAssignment): JsonResponse
{
$validated = $request->validate([
'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item'],
'macro_id' => ['sometimes', 'integer', 'exists:macros,id'],
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['sometimes', 'integer', 'min:0'],
]);
$macroAssignment->update($validated);
return response()->json(['success' => true]);
}
public function destroy(MacroAssignment $macroAssignment): JsonResponse
{
$macroAssignment->delete();
return response()->json(['success' => true]);
}
public function reorder(Request $request): JsonResponse
{
$validated = $request->validate([
'assignments' => ['required', 'array'],
'assignments.*.id' => ['required', 'integer', 'exists:macro_assignments,id'],
'assignments.*.order' => ['required', 'integer', 'min:0'],
]);
foreach ($validated['assignments'] as $item) {
MacroAssignment::where('id', $item['id'])->update(['order' => $item['order']]);
}
return response()->json(['success' => true]);
}
}

View file

@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\MacrosImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class MacroImportController extends Controller
{
public function __construct(
private readonly MacrosImportService $importService,
) {}
public function store(Request $request): JsonResponse
{
$request->validate(['file' => ['required', 'file', 'max:5120']]);
$file = $request->file('file');
$tempPath = $file->getPathname();
try {
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
} catch (Throwable $e) {
return response()->json([
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Makro-Datei ist.',
], 422);
}
return response()->json([
'stats' => [
'new' => $result->new,
'updated' => $result->updated,
'disabled' => $result->disabled,
're_enabled' => $result->reEnabled,
],
'warnings' => $result->warnings,
]);
}
}

View file

@ -49,23 +49,15 @@ public function importPro(Request $request): JsonResponse
public function downloadPro(Song $song): BinaryFileResponse public function downloadPro(Song $song): BinaryFileResponse
{ {
if ($this->countSongLabels($song) === 0) { if ($song->groups()->count() === 0) {
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.'); abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
} }
$exportService = app(ProExportService::class); $exportService = new ProExportService;
$tempPath = $exportService->generateProFile($song); $tempPath = $exportService->generateProFile($song);
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
return response()->download($tempPath, $filename)->deleteFileAfterSend(true); 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,12 +4,10 @@
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceAgendaItem; use App\Models\ServiceAgendaItem;
use App\Models\ServiceMacroOverride;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Slide; use App\Models\Slide;
use App\Models\Song; use App\Models\Song;
use App\Services\AgendaMatcherService; use App\Services\AgendaMatcherService;
use App\Services\MacroResolutionService;
use App\Services\ProBundleExportService; use App\Services\ProBundleExportService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -131,13 +129,15 @@ public function edit(Service $service): Response
$service->load([ $service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song', 'serviceSongs.song',
'serviceSongs.song.arrangements.arrangementLabels.label', 'serviceSongs.song.groups',
'serviceSongs.song.arrangements.arrangementGroups.group',
'serviceSongs.arrangement', 'serviceSongs.arrangement',
'slides', 'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides', 'agendaItems.slides',
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides', 'agendaItems.serviceSong.song.groups.slides',
'agendaItems.serviceSong.arrangement.arrangementLabels.label', 'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
]); ]);
$songsCatalog = Song::query() $songsCatalog = Song::query()
@ -227,34 +227,6 @@ public function edit(Service $service): Response
return $arr; return $arr;
}, $filteredItems); }, $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', [ return Inertia::render('Services/Edit', [
'service' => [ 'service' => [
'id' => $service->id, 'id' => $service->id,
@ -281,7 +253,15 @@ public function edit(Service $service): Response
'title' => $ss->song->title, 'title' => $ss->song->title,
'ccli_id' => $ss->song->ccli_id, 'ccli_id' => $ss->song->ccli_id,
'has_translation' => $ss->song->has_translation, 'has_translation' => $ss->song->has_translation,
'groups' => $this->collectSongLabels($ss->song), 'groups' => $ss->song->groups
->sortBy('order')
->values()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
]),
'arrangements' => $ss->song->arrangements 'arrangements' => $ss->song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1) ->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->values() ->values()
@ -289,13 +269,13 @@ public function edit(Service $service): Response
'id' => $arrangement->id, 'id' => $arrangement->id,
'name' => $arrangement->name, 'name' => $arrangement->name,
'is_default' => $arrangement->is_default, 'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementLabels 'groups' => $arrangement->arrangementGroups
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($arrangementLabel) => [ ->map(fn ($arrangementGroup) => [
'id' => $arrangementLabel->label?->id, 'id' => $arrangementGroup->group?->id,
'name' => $arrangementLabel->label?->name, 'name' => $arrangementGroup->group?->name,
'color' => $arrangementLabel->label?->color, 'color' => $arrangementGroup->group?->color,
]) ])
->filter(fn ($group) => $group['id'] !== null) ->filter(fn ($group) => $group['id'] !== null)
->values(), ->values(),
@ -322,7 +302,6 @@ public function edit(Service $service): Response
'title' => $nextService->title, 'title' => $nextService->title,
'date' => $nextService->date?->toDateString(), 'date' => $nextService->date?->toDateString(),
] : null, ] : null,
'macros_per_part' => $macros_per_part,
]); ]);
} }
@ -433,25 +412,4 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
) )
->deleteFileAfterSend(true); ->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

@ -1,94 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceMacroOverrideController extends Controller
{
public function store(Request $request, Service $service): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
]);
ServiceMacroOverride::firstOrCreate([
'service_id' => $service->id,
'part_type' => $validated['part_type'],
]);
$globals = MacroAssignment::where('part_type', $validated['part_type'])->orderBy('order')->get();
foreach ($globals as $global) {
ServiceMacroAssignment::firstOrCreate([
'service_id' => $service->id,
'part_type' => $validated['part_type'],
'macro_id' => $global->macro_id,
'position' => $global->position,
'label_id' => $global->label_id,
'order' => $global->order,
]);
}
return response()->json(['success' => true]);
}
public function destroy(Service $service, Request $request): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
]);
ServiceMacroOverride::where('service_id', $service->id)
->where('part_type', $validated['part_type'])
->delete();
ServiceMacroAssignment::where('service_id', $service->id)
->where('part_type', $validated['part_type'])
->delete();
return response()->json(['success' => true]);
}
public function storeAssignment(Request $request, Service $service): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
'macro_id' => ['required', 'integer', 'exists:macros,id'],
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['integer', 'min:0'],
]);
$assignment = ServiceMacroAssignment::create([
'service_id' => $service->id,
...$validated,
]);
return response()->json(['id' => $assignment->id, 'success' => true]);
}
public function updateAssignment(Request $request, Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
{
$validated = $request->validate([
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['sometimes', 'integer', 'min:0'],
]);
$serviceMacroAssignment->update($validated);
return response()->json(['success' => true]);
}
public function destroyAssignment(Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
{
$serviceMacroAssignment->delete();
return response()->json(['success' => true]);
}
}

View file

@ -2,10 +2,6 @@
namespace App\Http\Controllers; 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 App\Models\Setting;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -14,7 +10,11 @@
class SettingsController extends Controller class SettingsController extends Controller
{ {
private const AGENDA_KEYS = [ private const MACRO_KEYS = [
'macro_name',
'macro_uuid',
'macro_collection_name',
'macro_collection_uuid',
'agenda_start_title', 'agenda_start_title',
'agenda_end_title', 'agenda_end_title',
'agenda_announcement_position', 'agenda_announcement_position',
@ -24,31 +24,19 @@ class SettingsController extends Controller
public function index(): Response public function index(): Response
{ {
$settings = []; $settings = [];
foreach (self::AGENDA_KEYS as $key) { foreach (self::MACRO_KEYS as $key) {
$settings[$key] = Setting::get($key); $settings[$key] = Setting::get($key);
} }
return Inertia::render('Settings', [ return Inertia::render('Settings', [
'settings' => $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 public function update(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'key' => ['required', 'string', 'in:'.implode(',', self::AGENDA_KEYS)], 'key' => ['required', 'string', 'in:'.implode(',', self::MACRO_KEYS)],
'value' => ['nullable', 'string', 'max:500'], 'value' => ['nullable', 'string', 'max:500'],
]); ]);

View file

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

View file

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

View file

@ -18,48 +18,41 @@ public function __construct(
public function page(Song $song): Response public function page(Song $song): Response
{ {
$song->load([ $song->load([
'arrangements' => fn ($q) => $q->where('is_default', true), 'groups' => fn ($query) => $query
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'), ->orderBy('order')
'arrangements.arrangementLabels.label.songSlides', ->with([
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
]),
]); ]);
$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', [ return Inertia::render('Songs/Translate', [
'song' => [ 'song' => [
'id' => $song->id, 'id' => $song->id,
'title' => $song->title, 'title' => $song->title,
'ccli_id' => $song->ccli_id, 'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation, 'has_translation' => $song->has_translation,
'groups' => $groups, 'groups' => $song->groups->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
'slides' => $group->slides->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values(),
])->values(),
], ],
]); ]);
} }
/**
* 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 public function fetchUrl(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
@ -79,6 +72,11 @@ public function fetchUrl(Request $request): JsonResponse
]); ]);
} }
/**
* Übersetzungstext für einen Song importieren.
*
* Verteilt den Text zeilenweise auf die Slides des Songs.
*/
public function import(int $songId, Request $request): JsonResponse public function import(int $songId, Request $request): JsonResponse
{ {
$song = Song::find($songId); $song = Song::find($songId);
@ -100,6 +98,9 @@ public function import(int $songId, Request $request): JsonResponse
]); ]);
} }
/**
* Übersetzung eines Songs komplett entfernen.
*/
public function destroy(int $songId): JsonResponse public function destroy(int $songId): JsonResponse
{ {
$song = Song::find($songId); $song = Song::find($songId);

View file

@ -1,42 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Label extends Model
{
use HasFactory;
protected $fillable = [
'name',
'color',
'hidden_at',
'last_imported_at',
];
protected function casts(): array
{
return [
'hidden_at' => 'datetime',
'last_imported_at' => 'datetime',
];
}
public function 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;
}
}

View file

@ -1,51 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Macro extends Model
{
use HasFactory;
protected $fillable = [
'uuid',
'name',
'color',
'trigger_on_startup',
'image_type',
'action_count',
'hidden_at',
'last_imported_at',
'last_imported_filename',
];
protected function casts(): array
{
return [
'trigger_on_startup' => 'boolean',
'hidden_at' => 'datetime',
'last_imported_at' => 'datetime',
];
}
public function collections(): BelongsToMany
{
return $this->belongsToMany(MacroCollection::class, 'macro_collection_macros')
->withPivot('order')
->orderBy('macro_collection_macros.order');
}
public function assignments(): HasMany
{
return $this->hasMany(MacroAssignment::class);
}
public function isHidden(): bool
{
return $this->hidden_at !== null;
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MacroAssignment extends Model
{
protected $fillable = [
'part_type',
'macro_id',
'position',
'label_id',
'order',
];
public function macro(): BelongsTo
{
return $this->belongsTo(Macro::class);
}
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class MacroCollection extends Model
{
protected $fillable = [
'uuid',
'name',
'last_imported_at',
];
protected function casts(): array
{
return [
'last_imported_at' => 'datetime',
];
}
public function macros(): BelongsToMany
{
return $this->belongsToMany(Macro::class, 'macro_collection_macros')
->withPivot('order')
->orderBy('macro_collection_macros.order');
}
}

View file

@ -1,33 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceMacroAssignment extends Model
{
protected $fillable = [
'service_id',
'part_type',
'macro_id',
'position',
'label_id',
'order',
];
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function macro(): BelongsTo
{
return $this->belongsTo(Macro::class);
}
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
}

View file

@ -1,26 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ServiceMacroOverride extends Model
{
protected $fillable = [
'service_id',
'part_type',
];
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function assignments(): HasMany
{
return $this->hasMany(ServiceMacroAssignment::class, 'service_id', 'service_id')
->where('part_type', $this->part_type);
}
}

View file

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

View file

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

View file

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

35
app/Models/SongGroup.php Normal file
View file

@ -0,0 +1,35 @@
<?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; use HasFactory;
protected $fillable = [ protected $fillable = [
'label_id', 'song_group_id',
'order', 'order',
'text_content', 'text_content',
'text_content_translated', 'text_content_translated',
'notes', 'notes',
]; ];
public function label(): BelongsTo public function group(): BelongsTo
{ {
return $this->belongsTo(Label::class); return $this->belongsTo(SongGroup::class, 'song_group_id');
} }
} }

View file

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

View file

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

View file

@ -1,14 +0,0 @@
<?php
namespace App\Services\DTO;
final class MacroImportResult
{
public function __construct(
public readonly int $new,
public readonly int $updated,
public readonly int $disabled,
public readonly int $reEnabled,
public readonly array $warnings,
) {}
}

View file

@ -1,49 +0,0 @@
<?php
namespace App\Services;
use App\Models\Label;
use App\Models\Setting;
use App\Services\DTO\LabelImportResult;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\LabelsFileReader;
class LabelsImportService
{
public function import(string $filePath, string $originalFilename): LabelImportResult
{
$library = LabelsFileReader::read($filePath);
$newCount = 0;
$updatedCount = 0;
DB::transaction(function () use ($library, &$newCount, &$updatedCount): void {
foreach ($library->getLabels() as $parserLabel) {
$name = $parserLabel->getName();
if ($name === '') {
continue;
}
$color = $parserLabel->getColorHex();
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
if ($existing === null) {
Label::create([
'name' => $name,
'color' => $color,
'last_imported_at' => now(),
]);
$newCount++;
} else {
$existing->update([
'color' => $color,
'last_imported_at' => now(),
]);
$updatedCount++;
}
}
});
Setting::set('labels_last_imported_at', now()->toIso8601String());
Setting::set('labels_last_imported_filename', $originalFilename);
return new LabelImportResult($newCount, $updatedCount, count($library->getLabels()));
}
}

View file

@ -1,85 +0,0 @@
<?php
namespace App\Services;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use Illuminate\Support\Collection;
class MacroResolutionService
{
/**
* Returns active (non-hidden) assignments for a given service + part type.
* Uses service-specific assignments if an override exists, otherwise global defaults.
*/
public function resolveAssignmentsForPart(Service $service, string $partType): Collection
{
$hasOverride = ServiceMacroOverride::where('service_id', $service->id)
->where('part_type', $partType)
->exists();
if ($hasOverride) {
$rows = ServiceMacroAssignment::with(['macro', 'label'])
->where('service_id', $service->id)
->where('part_type', $partType)
->orderBy('order')
->get();
} else {
$rows = MacroAssignment::with(['macro', 'label'])
->where('part_type', $partType)
->orderBy('order')
->get();
}
return $rows
->reject(fn ($r) => $r->macro === null || $r->macro->isHidden())
->reject(fn ($r) => $r->position === 'by_label' && ($r->label === null || $r->label->isHidden()));
}
/**
* Returns the macro export data for macros that apply to a specific slide.
*
* @param array $slideContext ['index' => int, 'total' => int, 'label_id' => int|null]
* @return array<int, array{name: string, uuid: string, collectionName: string, collectionUuid: string}>
*/
public function macrosForSlide(Service $service, string $partType, array $slideContext): array
{
$assignments = $this->resolveAssignmentsForPart($service, $partType);
$matched = $assignments->filter(function ($a) use ($slideContext) {
return match ($a->position) {
'all_slides' => true,
'first_slide' => $slideContext['index'] === 0,
'last_slide' => $slideContext['index'] === $slideContext['total'] - 1,
'by_label' => isset($slideContext['label_id'])
&& (int) $a->label_id === (int) $slideContext['label_id'],
default => false,
};
});
return $matched->map(fn ($a) => $this->toExportArray($a->macro))->values()->all();
}
/**
* Returns the count of active assignments for a service + part (for UI badges).
*/
public function countAssignmentsForPart(Service $service, string $partType): int
{
return $this->resolveAssignmentsForPart($service, $partType)->count();
}
private function toExportArray(Macro $macro): array
{
$collection = $macro->collections()->first();
return [
'name' => $macro->name,
'uuid' => $macro->uuid,
'collectionName' => $collection?->name ?? '--MAIN--',
'collectionUuid' => $collection?->uuid ?? '8D02FC57-83F8-4042-9B90-81C229728426',
];
}
}

View file

@ -1,110 +0,0 @@
<?php
namespace App\Services;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use App\Services\DTO\MacroImportResult;
use App\Support\MacroColorConverter;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\MacrosFileReader;
class MacrosImportService
{
public function import(string $filePath, string $originalFilename): MacroImportResult
{
$library = MacrosFileReader::read($filePath);
$stats = ['new' => 0, 'updated' => 0, 'disabled' => 0, 'reEnabled' => 0];
$importedUuids = [];
DB::transaction(function () use ($library, &$stats, &$importedUuids, $originalFilename): void {
foreach ($library->getMacros() as $parserMacro) {
$uuid = strtoupper($parserMacro->getUuid());
if ($uuid === '') {
continue;
}
$importedUuids[] = $uuid;
$color = MacroColorConverter::fromRgba($parserMacro->getColor());
$data = [
'uuid' => $uuid,
'name' => $parserMacro->getName(),
'color' => $color,
'trigger_on_startup' => $parserMacro->getTriggerOnStartup(),
'image_type' => $parserMacro->getImageType(),
'action_count' => $parserMacro->getActionCount(),
'last_imported_at' => now(),
'last_imported_filename' => $originalFilename,
'hidden_at' => null,
];
$existing = Macro::where('uuid', $uuid)->first();
if ($existing === null) {
Macro::create($data);
$stats['new']++;
} else {
$wasHidden = $existing->isHidden();
$existing->update($data);
if ($wasHidden) {
$stats['reEnabled']++;
} else {
$stats['updated']++;
}
}
}
if (! empty($importedUuids)) {
$stats['disabled'] = Macro::whereNotIn('uuid', $importedUuids)
->whereNull('hidden_at')
->update(['hidden_at' => now()]);
}
foreach ($library->getCollections() as $parserCollection) {
$collUuid = strtoupper($parserCollection->getUuid());
if ($collUuid === '') {
continue;
}
$collection = MacroCollection::updateOrCreate(
['uuid' => $collUuid],
['name' => $parserCollection->getName(), 'last_imported_at' => now()],
);
$collection->macros()->detach();
foreach ($parserCollection->getMacroUuids() as $idx => $macroUuid) {
$macro = Macro::where('uuid', strtoupper($macroUuid))->first();
if ($macro) {
$collection->macros()->attach($macro->id, ['order' => $idx]);
}
}
}
});
Setting::set('macros_last_imported_at', now()->toIso8601String());
Setting::set('macros_last_imported_filename', $originalFilename);
$warnings = $this->buildAssignmentWarnings();
return new MacroImportResult(
$stats['new'],
$stats['updated'],
$stats['disabled'],
$stats['reEnabled'],
$warnings,
);
}
private function buildAssignmentWarnings(): array
{
return MacroAssignment::whereHas('macro', fn ($q) => $q->whereNotNull('hidden_at'))
->with('macro')
->get()
->map(fn ($a) => [
'macro_name' => $a->macro->name,
'macro_uuid' => $a->macro->uuid,
'part_type' => $a->part_type,
])
->toArray();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +0,0 @@
<?php
namespace App\Support;
final class MacroColorConverter
{
public static function fromRgba(?array $rgba): ?string
{
if ($rgba === null) {
return null;
}
return sprintf(
'#%02X%02X%02X',
(int) round(max(0.0, min(1.0, $rgba['r'])) * 255),
(int) round(max(0.0, min(1.0, $rgba['g'])) * 255),
(int) round(max(0.0, min(1.0, $rgba['b'])) * 255),
);
}
}

View file

@ -15,7 +15,7 @@
} }
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.2",
"5pm-hdh/churchtools-api": "^2.1", "5pm-hdh/churchtools-api": "^2.1",
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"inertiajs/inertia-laravel": "^2.0", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "87837501106e784aa10ddd7743056cba", "content-hash": "424677667864ca1fffd6f4af9632aa92",
"packages": [ "packages": [
{ {
"name": "5pm-hdh/churchtools-api", "name": "5pm-hdh/churchtools-api",
@ -3819,7 +3819,7 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git", "url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git",
"reference": "9e3e719806d8db3941444b8424fdd56b3b534aa8" "reference": "22ba4aff7d29683297c0397e1bbc3699dc35ac03"
}, },
"require": { "require": {
"google/protobuf": "^4.0", "google/protobuf": "^4.0",
@ -3838,7 +3838,7 @@
} }
}, },
"description": "ProPresenter song file parser", "description": "ProPresenter song file parser",
"time": "2026-05-03T19:40:09+00:00" "time": "2026-03-30T11:26:29+00:00"
}, },
{ {
"name": "psr/clock", "name": "psr/clock",
@ -10867,8 +10867,8 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.4" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.9.0" "plugin-api-version": "2.2.0"
} }

View file

@ -1,21 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Label;
use Illuminate\Database\Eloquent\Factories\Factory;
class LabelFactory extends Factory
{
protected $model = Label::class;
public function definition(): array
{
return [
'name' => $this->faker->unique()->words(2, true),
'color' => sprintf('#%06X', mt_rand(0, 0xFFFFFF)),
'hidden_at' => null,
'last_imported_at' => null,
];
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Macro;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class MacroFactory extends Factory
{
protected $model = Macro::class;
public function definition(): array
{
return [
'uuid' => strtoupper(Str::uuid()->toString()),
'name' => $this->faker->words(3, true),
'color' => sprintf('#%06X', mt_rand(0, 0xFFFFFF)),
'trigger_on_startup' => false,
'image_type' => 0,
'action_count' => 0,
'hidden_at' => null,
'last_imported_at' => null,
'last_imported_filename' => null,
];
}
}

View file

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

View file

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

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

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

View file

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('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

@ -1,50 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('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

@ -1,27 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('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

@ -1,37 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('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

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

@ -1,39 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('song_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

@ -1,71 +0,0 @@
<?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,16 +67,7 @@ watch(
watch( watch(
selectedArrangement, selectedArrangement,
(arrangement) => { (arrangement) => {
if (arrangement?.groups) { arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
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 }, { immediate: true },
) )
@ -166,7 +157,7 @@ function saveArrangement() {
`/arrangements/${selectedArrangement.value.id}`, `/arrangements/${selectedArrangement.value.id}`,
{ {
groups: arrangementGroups.value.map((group, index) => ({ groups: arrangementGroups.value.map((group, index) => ({
label_id: group.id, song_group_id: group.id,
order: index + 1, order: index + 1,
})), })),
}, },

View file

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

View file

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

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

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

@ -1,145 +0,0 @@
<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, name: arr.name,
is_default: arr.is_default, is_default: arr.is_default,
groups: arr.arrangement_groups.map((ag) => { groups: arr.arrangement_groups.map((ag) => {
const group = songData.value.groups.find((g) => g.id === ag.label_id) const group = songData.value.groups.find((g) => g.id === ag.song_group_id)
return { return {
id: ag.label_id, id: ag.song_group_id,
name: group?.name ?? 'Unbekannt', name: group?.name ?? 'Unbekannt',
color: group?.color ?? '#6b7280', color: group?.color ?? '#6b7280',
order: ag.order, order: ag.order,

View file

@ -6,8 +6,6 @@ import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
import AgendaItemRow from '@/Components/AgendaItemRow.vue' import AgendaItemRow from '@/Components/AgendaItemRow.vue'
import SongAgendaItem from '@/Components/SongAgendaItem.vue' import SongAgendaItem from '@/Components/SongAgendaItem.vue'
import ArrangementDialog from '@/Components/ArrangementDialog.vue' import ArrangementDialog from '@/Components/ArrangementDialog.vue'
import MacroIcon from '@/Components/MacroIcon.vue'
import ServicePartMacroPanel from '@/Components/ServicePartMacroPanel.vue'
const props = defineProps({ const props = defineProps({
service: { service: {
@ -42,30 +40,8 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), 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(() => { const formattedDate = computed(() => {
if (!props.service.date) return '' if (!props.service.date) return ''
return new Date(props.service.date).toLocaleDateString('de-DE', { return new Date(props.service.date).toLocaleDateString('de-DE', {
@ -116,9 +92,9 @@ function getArrangements(item) {
name: arr.name, name: arr.name,
is_default: arr.is_default, is_default: arr.is_default,
groups: (arr.arrangement_groups ?? []).map((ag) => { groups: (arr.arrangement_groups ?? []).map((ag) => {
const group = song.groups?.find((g) => g.id === ag.label_id) ?? ag.group ?? {} const group = song.groups?.find((g) => g.id === ag.song_group_id) ?? ag.group ?? {}
return { return {
id: ag.label_id ?? group.id, id: ag.song_group_id ?? group.id,
name: group.name ?? 'Unbekannt', name: group.name ?? 'Unbekannt',
color: group.color ?? '#6b7280', color: group.color ?? '#6b7280',
order: ag.order, order: ag.order,
@ -382,31 +358,7 @@ async function downloadService() {
<!-- Ablauf (Agenda) --> <!-- Ablauf (Agenda) -->
<div class="py-6"> <div class="py-6">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="mb-4 flex items-center justify-between gap-3"> <h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3>
<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 --> <!-- Empty state -->
<div v-if="!agendaItems || agendaItems.length === 0" <div v-if="!agendaItems || agendaItems.length === 0"
@ -474,28 +426,10 @@ 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" /> <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> </svg>
</div> </div>
<div class="flex-1"> <div>
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3> <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> <p class="text-xs text-gray-500">Info-Folien für alle kommenden Services</p>
</div> </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> </div>
<InformationBlock <InformationBlock
:service-id="service.id" :service-id="service.id"

View file

@ -1,41 +1,70 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' 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 { Head } from '@inertiajs/vue3'
import { onMounted, ref } from 'vue' import { ref, reactive } from 'vue'
const props = defineProps({ const props = defineProps({
settings: { type: Object, default: () => ({}) }, settings: {
assignments: { type: Array, default: () => [] }, type: Object,
macros: { type: Array, default: () => [] }, default: () => ({}),
labels: { type: Array, default: () => [] }, },
collections: { type: Array, default: () => [] },
last_macros_import: { type: Object, default: () => ({}) },
last_labels_import: { type: Object, default: () => ({}) },
}) })
const submenus = [ const fields = [
{ key: 'assignments', label: 'Makro-Zuweisungen' }, // Macro configuration fields
{ key: 'macros', label: 'Makro-Import' }, { key: 'macro_name', label: 'Makro-Name', placeholder: 'z.B. Copyright Makro', section: 'macro' },
{ key: 'labels', label: 'Label-Import' }, { key: 'macro_uuid', label: 'Makro-UUID', placeholder: 'z.B. 11111111-2222-3333-4444-555555555555', section: 'macro' },
{ key: 'agenda', label: 'Agenda' }, { 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 activeSubmenu = ref('assignments') const form = reactive({})
for (const field of fields) {
form[field.key] = props.settings[field.key] ?? field.defaultValue ?? ''
}
onMounted(() => { const saving = reactive({})
const hash = window.location.hash.replace('#', '') const saved = reactive({})
if (submenus.some((s) => s.key === hash)) { const errors = reactive({})
activeSubmenu.value = hash
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
} }
})
function switchSubmenu(key) {
activeSubmenu.value = key
window.location.hash = key
} }
</script> </script>
@ -50,78 +79,148 @@ function switchSubmenu(key) {
</template> </template>
<div class="py-8"> <div class="py-8">
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-3xl 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="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm">
<div class="flex min-h-[400px]"> <div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
<!-- Sidebar (desktop) --> <h3 class="text-sm font-semibold text-gray-900">
<div class="hidden w-48 shrink-0 border-r border-gray-100 sm:block"> ProPresenter Makro-Konfiguration
<nav class="flex flex-col gap-1 p-2"> </h3>
<button <p class="mt-1 text-xs text-gray-500">
v-for="item in submenus" Diese Einstellungen werden beim Export auf Copyright-Folien als Makro-Aktion angewendet.
:key="item.key" </p>
:data-testid="'settings-submenu-' + item.key" </div>
@click="switchSubmenu(item.key)"
:class="[ <div class="divide-y divide-gray-100 px-6">
'w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors', <div
activeSubmenu === item.key v-for="field in fields.filter(f => f.section === 'macro')"
? 'border-l-2 border-amber-500 bg-amber-50 text-amber-700' :key="field.key"
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900', 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"
> >
{{ item.label }} <svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
</button> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
</nav> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</div> </svg>
</div>
<!-- Mobile tab bar --> <div
<div class="w-full border-b border-gray-100 sm:hidden"> v-else-if="saved[field.key]"
<nav class="flex overflow-x-auto"> class="absolute inset-y-0 right-0 flex items-center pr-3"
<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',
]"
> >
{{ item.label }} <svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
</button> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</nav> </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>
</div> </div>
</div>
</div>
<!-- Content panel --> <div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm mt-6">
<div class="flex-1 p-6" data-testid="settings-active-panel"> <div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
<MacroAssignments <h3 class="text-sm font-semibold text-gray-900">Agenda-Konfiguration</h3>
v-if="activeSubmenu === 'assignments'" <p class="mt-1 text-xs text-gray-500">Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.</p>
:assignments="assignments" </div>
:macros="macros" <div class="divide-y divide-gray-100 px-6">
:labels="labels" <div v-for="field in fields.filter(f => f.section === 'agenda')" :key="field.key" class="py-5">
:collections="collections" <label
@switch-submenu="switchSubmenu" :for="'setting-' + field.key"
/> class="block text-sm font-medium text-gray-700"
>
{{ field.label }}
</label>
<MacroImport <div class="relative mt-1.5">
v-if="activeSubmenu === 'macros'" <input
:macros="macros" :id="'setting-' + field.key"
:collections="collections" :data-testid="'setting-' + field.key"
:last_macros_import="last_macros_import" v-model="form[field.key]"
@switch-submenu="switchSubmenu" 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)"
/>
<LabelImport <div
v-if="activeSubmenu === 'labels'" v-if="saving[field.key]"
:labels="labels" class="absolute inset-y-0 right-0 flex items-center pr-3"
:last_labels_import="last_labels_import" >
/> <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>
<AgendaSettings <div
v-if="activeSubmenu === 'agenda'" v-else-if="saved[field.key]"
:settings="settings" 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>
<p
v-if="field.defaultValue"
class="mt-1.5 text-xs text-gray-400"
>
Standard: {{ field.defaultValue }}
</p>
</div> </div>
</div> </div>
</div> </div>

View file

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

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

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

@ -1,181 +0,0 @@
<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,11 +2,7 @@
use App\Http\Controllers\ApiLogController; use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\AuthController; 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\ServiceController;
use App\Http\Controllers\ServiceMacroOverrideController;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\SongPdfController; use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\SyncController; use App\Http\Controllers\SyncController;
@ -94,34 +90,4 @@
Route::post('/slides/reorder', '\\App\\Http\\Controllers\\SlideController@reorder')->name('slides.reorder'); 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::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'); 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,14 +2,12 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceAgendaItem; use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong; use App\Models\ServiceSong;
use App\Models\Slide; use App\Models\Slide;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide; use App\Models\SongSlide;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -104,18 +102,8 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
$service = Service::factory()->create(); $service = Service::factory()->create();
$song = Song::factory()->create(['title' => 'Amazing Grace']); $song = Song::factory()->create(['title' => 'Amazing Grace']);
$label = Label::factory()->create(['name' => 'Verse 1']); $group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]);
SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]); SongSlide::factory()->create(['song_group_id' => $group->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([ $serviceSong = ServiceSong::create([
'service_id' => $service->id, 'service_id' => $service->id,

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -2,14 +2,8 @@
namespace Tests\Feature; 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\Song;
use App\Models\User; use App\Models\User;
use App\Services\ProExportService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -28,23 +22,17 @@ private function createSongWithContent(): Song
'publisher' => 'Test Publisher', 'publisher' => 'Test Publisher',
]); ]);
$verse = Label::firstOrCreate( $verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
['name' => 'Verse 1 - Export Test Song'], $verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
['color' => '#2196F3'], $verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
);
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$chorus = Label::firstOrCreate( $chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
['name' => 'Chorus - Export Test Song'], $chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]); $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]); $arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]); $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]);
return $song; return $song;
} }
@ -94,9 +82,7 @@ public function test_download_pro_roundtrip_import_export(): void
$song = Song::find($songId); $song = Song::find($songId);
$this->assertNotNull($song); $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 = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk(); $exportResponse->assertOk();
@ -106,6 +92,7 @@ public function test_download_pro_roundtrip_preserves_content(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
// 1. Import the reference .pro file
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro'); $sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true); $file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
@ -113,45 +100,43 @@ public function test_download_pro_roundtrip_preserves_content(): void
$importResponse->assertOk(); $importResponse->assertOk();
$songId = $importResponse->json('songs.0.id'); $songId = $importResponse->json('songs.0.id');
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId); $originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId);
$this->assertNotNull($originalSong); $this->assertNotNull($originalSong);
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first(); // Snapshot original data
$this->assertNotNull($defaultArr); $originalGroups = $originalSong->groups->sortBy('order')->values();
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements; $originalArrangements = $originalSong->arrangements;
// 2. Export as .pro
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk(); $exportResponse->assertOk();
// Save exported content to temp file — BinaryFileResponse delivers a real file
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro'; $tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */ /** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
$baseResponse = $exportResponse->baseResponse; $baseResponse = $exportResponse->baseResponse;
copy($baseResponse->getFile()->getPathname(), $tempPath); 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); $reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
@unlink($tempPath); @unlink($tempPath);
// 4. Assert song name
$this->assertSame($originalSong->title, $reImported->getName()); $this->assertSame($originalSong->title, $reImported->getName());
// 5. Assert groups match (same names, same order)
$reImportedGroups = $reImported->getGroups(); $reImportedGroups = $reImported->getGroups();
$this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch');
$uniqueOriginalLabels = $originalArrangementLabels foreach ($originalGroups as $index => $originalGroup) {
->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]; $reImportedGroup = $reImportedGroups[$index];
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}"); $this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
$originalSlides = $originalLabel->songSlides->sortBy('order')->values(); // Assert slides within group
$originalSlides = $originalGroup->slides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup); $reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'"); $this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'");
foreach ($originalSlides as $slideIndex => $originalSlide) { foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex]; $reImportedSlide = $reImportedSlides[$slideIndex];
@ -159,30 +144,32 @@ public function test_download_pro_roundtrip_preserves_content(): void
$this->assertSame( $this->assertSame(
$originalSlide->text_content, $originalSlide->text_content,
$reImportedSlide->getPlainText(), $reImportedSlide->getPlainText(),
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}" "Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
); );
// Assert translation if present
if ($originalSlide->text_content_translated) { if ($originalSlide->text_content_translated) {
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}"); $this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}");
$this->assertSame( $this->assertSame(
$originalSlide->text_content_translated, $originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(), $reImportedSlide->getTranslation()?->getPlainText(),
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}" "Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
); );
} }
} }
} }
// 6. Assert arrangements match (same names, same group order)
$reImportedArrangements = $reImported->getArrangements(); $reImportedArrangements = $reImported->getArrangements();
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch'); $this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
foreach ($originalArrangements as $originalArrangement) { foreach ($originalArrangements as $index => $originalArrangement) {
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name); $reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import"); $this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementLabels $originalGroupNames = $originalArrangement->arrangementGroups
->sortBy('order') ->sortBy('order')
->map(fn ($al) => $al->label?->name) ->map(fn ($ag) => $ag->group?->name)
->filter() ->filter()
->values() ->values()
->toArray(); ->toArray();
@ -199,6 +186,7 @@ public function test_download_pro_roundtrip_preserves_content(): void
); );
} }
// 7. Assert CCLI metadata
if ($originalSong->ccli_id) { if ($originalSong->ccli_id) {
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber()); $this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
} }
@ -207,96 +195,6 @@ 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 private function assertStringContains(string $needle, ?string $haystack): void
{ {
$this->assertNotNull($haystack); $this->assertNotNull($haystack);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

Binary file not shown.

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