# 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.
