pp-planer/AGENTS.md
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00

406 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

ChurchService presenter software show creator
## Description
There is tool (churchtools - called cts from now on) to plan a church service, that contains all parts of the service (information, songs, sermon, prayer, etc.).
Use CTS API for services: <https://api.church.tools/package-CT.ChurchService.html>.
All the wording in the frontend and communication has to be in German with Du, not Sie.
## TechStack
- Laravel (Vue+Inertia) App, with Sqlite (switchable to MySQL)
- use DB for caching the API data and just "Update" the DB from the API
- use CTS API with existing env `CTS_API_TOKEN` (.env for LIVE, .env.example with same content for you) for auth - ONLY DO READS, NO WRITES OR CHANGES ARE ALLOWED
## General
- Login should be done via OAUTH for all churchtools users (<https://churchtools.academy/de/help/system-einstellungen/oauth-login-systemeinstellungen/oauth-zwischen-zwei-churchtools-systemen/>) and linked to an automatically created local user.
- There should be Button in the Top Bar, to refresh the Data from the CTS API and a timestamp with the latest refresh.
- LoggedIn User should be visible in the Top Bar
- every action should be immediately persistent, no separate "save" button required, unless explicitly described.
- ProPresenter `.pro` file parser/generator is implemented as a separate composer package (`propresenter/parser`) linked via path repository
## The Plan
We use the data from the API, to create a form to finalize the service setup and to create a file for the presenter software at the end.
1. Show all today or future services in a list, with details (Title, Preacher, 'beamer technican', qty of songs, last changed, finalized_at) and state of the setup (No or Yes at...):
- x/y songs found and mapped
- x/y songs verified arrangement
- sermon slides uploaded
- X info slides uploaded
- finalized at
2. for every service, show Action Buttons:
- if finalized: ReOpen and Download
- if NOT finalized: Edit and Finalize
3. - ReOpen and Finalize just change the status of the service
- Edit shows a form, with these Blocks to edit (details of these blocks are below)
- information
- moderation
- sermon
- songs
## Form Blocks
### Block: Information
- show list of thumbnails for all uploaded slides with a muted upload date field with uploader name, and a prominent Expiredate field
- each thumbnail could be delete (softdelete) or can inline change the date with a datepicker
- add big plus icon/area for drag'n'drop or click for fileupload new files with a datepicker for a date, that was added to all files as expire date
- automatically show these files to all future services, till the expire date is after the service date
### Block: Moderation
Same features as `Block Information` but without the datepicker and only relevant for this service.
### Block: Sermon
Same features as `Block Moderation`.
### Block: Songs
- Show all songs (Name, CCLI ID, has Translation ..) from the service in the right order.
- on every update from the CTS API, try to match the song with the CCLI-ID to an existing song from the DB
- if NOT matched
- show a button "request creation", which causes an EMAIL to a configured mail address, with the song and the CCLI Id and the ask for create the song
- and show a searchable select field of all songs in the DB (CCLI ID included and searchable) to manually assign a song from the DB to this service song
- if song found:
- if song is translated, show checkbox (default:true) for use the version with translation
- every song has a body for the arrangement selection/configuration
- select field with all existing arrangements
- "add" Button to create a new arangmengt (clone from master order) and ask for a name
- "clone" Button to clone the selected arragenement and ask for a name
- show the groups of the arrangement and make possible to rearange or add a group to the arrangement via drag'n'drop, like `rev/form-song-arangment-config.jpg`
- every song with a selected arrangement ("normal" should selected always as default) should have two buttons:
- preview: show the text of the song in the order of the arrangement configuration, prominent highlighted which textpart was with group
- download: download the preview as a nice pdf with header/footer and copyright footer from the Song DB.
## File Upload
- could be a zip file, contained multiple other files of types below, handle as mass upload
- could me multiple files, so handle each one as a single file for types below
- could be an image file (png, jpg, jpeg) -> always convert to jpg 1920x1080 (dont cut parts of an image, keep orientation and ratio)
- could be a ppt or pptx (powerpoint) -> convert to multiple JPG with 1920x1080 (see jpg convert)
## SongDB Import
There should be a menu item for songDB in the Top.
- it shows all songs from the DB, with created, last update, ccliID, last_used_in_service every song has a delete (soft_delete), download, translate and an edit button
- edit: shows a popup with Name, CCLI and copyright text (all that is available from song metadata) and the arrangement configurator from the service->song block
- download: download generated .pro file from the songDB for this song
- translate: allow add a full text or an URL to the Full text, and then start an editor, that shows two columns for every slide of every group. Left the original text, right a texteditor, with the imported text - always the same line qty of text from the original is used from the given translated text. Save this as translation for this song, and mark it as `with translation`.
- UploadArea for drag'n'drop and click for upload, to upload a .pro file, a zip file with multiple .pro files, or a bunch of .pro files, which should be parsed (this module was integrated later, so show an Exception here till this was finalized) and added into the song DB.
---
## CCLI Import
Songs can be imported from CCLI SongSelect via a paste-based flow (no server-side scraping — ToS-compliant).
### How it works
1. User opens a song on **songselect.ccli.com** in their browser (must be logged in with their CCL account)
2. User copies the full page text (Ctrl+A → Ctrl+C)
3. User clicks "Aus CCLI importieren" in the SongDB or service form, pastes the text, clicks "Vorschau", then "Importieren"
Alternatively, install the **browser bookmarklet** from Settings → CCLI Import. The bookmarklet automates step 2-3 in two clicks.
### Key files
| File | Purpose |
|------|---------|
| `app/Services/CcliPasteParser.php` | Pure-PHP parser: extracts title/author/CCLI-Nr/year/sections from pasted text |
| `app/Services/CcliImportService.php` | Upserts Song + Arrangement + Labels + Slides; handles duplicates and soft-delete restore |
| `app/Services/CcliTranslationPairingService.php` | Auto-pairs CCLI sections with local song labels for translation import |
| `app/Support/CcliLabels.php` | Section-label regex + EN↔DE name mapping (Verse↔Strophe, Chorus↔Refrain, etc.) |
| `app/Http/Controllers/CcliPasteController.php` | POST /api/ccli/preview + POST /api/songs/import-from-ccli-paste (3 modes) |
| `app/Http/Controllers/BookmarkletController.php` | GET /bookmarklets/ccli-import.js — serves the bookmarklet JS |
| `resources/js/Components/CcliPasteDialog.vue` | Modal: textarea → preview → import buttons (surface-aware) |
| `resources/js/Pages/Songs/ImportFromCcliPaste.vue` | Bookmarklet redirect landing page |
| `tests/Fixtures/ccli/` | 22 synthetic CCLI-format fixture files for parser tests |
### Test coverage
- `tests/Feature/CcliPasteParserTest.php` — parser against all 22 fixtures
- `tests/Feature/CcliImportServiceTest.php` — upsert, duplicate, restore, transaction
- `tests/Feature/CcliTranslationPairingServiceTest.php` — label pairing, line distribution
- `tests/Feature/CcliPasteControllerTest.php` — API endpoints, auth, throttle
- `tests/e2e/ccli-paste-import.spec.ts` — SongDB import flow
- `tests/e2e/ccli-bookmarklet.spec.ts` — bookmarklet endpoint + Settings page
- `tests/e2e/ccli-translation-pairing.spec.ts` — Translate.vue prefill
### Maintenance note
If SongSelect changes their HTML structure, update the bookmarklet DOM selectors in `resources/js/bookmarklet/ccli-import.ts` (or the inline JS in `BookmarkletController.php`). The server-side parser (`CcliPasteParser.php`) is independent of SongSelect's HTML — it only parses the plain-text lyrics format.
---
## KeyVisual & Background
Each service export can carry a key-visual image (shown as a standalone fallback slide) and a background image (rendered as a media layer behind every song/sermon slide).
Resolution is lazy and happens at export time:
1. Per-service column (`key_visual_filename` / `background_filename` on the `services` table)
2. Global default from Settings (`current_key_visual` / `current_background`)
3. None (slide omitted / no background layer)
When a service is finalized, the resolved filenames are snapshotted into the per-service columns so the export is stable even if the global default changes later.
### Export naming contract (portable bundles)
On export the key-visual and background images are **embedded into the archive** under fixed names and referenced **bundle-relative** inside the `.pro` file (never by absolute path), so exports are portable to the presenter PC:
| Image | Embedded filename + `.pro` reference |
|-------|--------------------------------------|
| Key-visual | `KEY_VISUAL.jpg` |
| Background | `BACKGROUND.jpg` |
The fixed names are defined as `ServiceImageResolver::KEY_VISUAL_EXPORT_NAME` / `ServiceImageResolver::BACKGROUND_EXPORT_NAME`. The `slideData['background']` array carries `'path' => '<FIXED_NAME>'` with `'bundleRelative' => true`; the image bytes are added to the archive's embedded/media files under that same name (deduplicated per archive). Applies to `.proplaylist` (`PlaylistExportService`) and `.probundle` (`ProBundleExportService`). The bare single-song `.pro` download has no service context and carries no background.
### Key files
| File | Purpose |
|------|---------|
| `app/Services/ServiceImageResolver.php` | Lazy resolution: per-service column → global Setting → null |
| `app/Http/Controllers/ServiceImageController.php` | `POST /services/{service}/key-visual` + `POST /services/{service}/background`; scope dialog ("Nur für diesen Service" / "Als Standard setzen") |
| `app/Services/FileConversionService.php` | Added `convertImageCover()` for COVER-mode 1920×1080 conversion |
| `app/Services/PlaylistExportService.php` | Injects keyvisual fallback slides and sermon sequence (keyvisual → nametag → slides) |
| `app/Services/ProExportService.php` | Adds background media layer on song/sermon slides |
| `resources/js/Components/ServiceImagePanel.vue` | Upload panel with scope dialog; used on the service Edit page |
| `resources/js/Pages/Services/Edit.vue` | Image panels rendered at the top of the edit form |
| `resources/js/Pages/Settings.vue` | Global default key-visual and background fields |
### Settings keys
| Key | Purpose |
|-----|---------|
| `current_key_visual` | Global default key-visual filename |
| `current_background` | Global default background filename |
### Routes
| Method | Route | Name |
|--------|-------|------|
| POST | `/services/{service}/key-visual` | `services.key-visual.store` |
| POST | `/services/{service}/background` | `services.background.store` |
### Parser package changes (commit `582ef85`)
- New `slideData['background']` contract: background-layer media action on any slide
- New `slideData['imageOnly']` flag: image-only slide (no text layer)
- New Slide read accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
---
## NameTag (Namenseinblender)
A name-tag slide is injected into the sermon sequence (between the key-visual and the sermon slides) to display the moderator and/or preacher name on screen.
The slide is only generated when the Namenseinblender macro is fully configured in Settings. It renders plain white text and optionally triggers a ProPresenter macro.
### Name resolution order
1. `moderator_name` / `preacher_name_override` columns on the `services` table (manual override set via Edit form)
2. Responsible person from the CTS `responsible` JSON field matching the configured role
3. None (slide omitted)
### Key files
| File | Purpose |
|------|---------|
| `app/Services/NameTagResolver.php` | Resolves moderator/preacher name: responsible JSON → CTS role → manual override |
| `app/Services/NameTagSlideBuilder.php` | Builds `slideData` for the nametag slide (plain white text + optional macro) |
| `app/Http/Controllers/ServiceController.php` | `PATCH /services/{service}/name-overrides` — saves manual name overrides |
| `resources/js/Pages/Services/Edit.vue` | Name override input fields in the edit form |
| `resources/js/Pages/Settings.vue` | Namenseinblender submenu with macro name/UUID/collection fields |
### Settings keys
| Key | Purpose |
|-----|---------|
| `namenseinblender_macro_name` | ProPresenter macro name |
| `namenseinblender_macro_uuid` | ProPresenter macro UUID |
| `namenseinblender_macro_collection_name` | Macro collection name |
| `namenseinblender_macro_collection_uuid` | Macro collection UUID |
### Routes
| Method | Route | Name |
|--------|-------|------|
| PATCH | `/services/{service}/name-overrides` | `services.name-overrides.update` |
---
## Repository Structure
Two git repositories, both local (no remote):
| Repo | Path | Branch | Purpose |
|------|------|--------|---------|
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
## Build, Test, Lint Commands
### pp-planer (Laravel App)
Local dev runs in **DDEV** (Docker). Site URL: `https://pp-planer.ddev.site`.
```bash
# Easy onboarding wrappers (no DDEV knowledge required):
./start_dev.sh # ddev start + spawns dev workers in background
./stop_dev.sh # stops workers and DDEV (use --keep-ddev to leave DDEV running)
# Or use DDEV directly:
ddev start # composer install, key:generate, migrate, npm install, npm run build
ddev dev # queue + log tail + vite HMR (foreground)
ddev stop # stop the project
# Open a shell inside the web container
ddev ssh
# Frontend
ddev npm run build
ddev npm run dev
# Run ALL PHP tests (clears config cache first)
ddev composer test
ddev exec php artisan test
# Single test file
ddev exec php artisan test tests/Feature/ServiceControllerTest.php
# Single test method
ddev exec php artisan test --filter=test_service_kann_abgeschlossen_werden
# Test suite
ddev exec php artisan test --testsuite=Feature
ddev exec php artisan test --testsuite=Unit
# PHP formatting (Laravel Pint, default preset — no pint.json)
ddev exec ./vendor/bin/pint
ddev exec ./vendor/bin/pint --test # check only
# E2E tests (requires `ddev start` running; baseURL is https://pp-planer.ddev.site)
npx playwright test
npx playwright test tests/e2e/service-list.spec.ts
# Migrations
ddev exec php artisan migrate
```
### propresenter (Parser Module)
```bash
cd /Users/thorsten/AI/propresenter
# Run all tests (230 tests)
./vendor/bin/phpunit
# Single test file
./vendor/bin/phpunit tests/ProFileReaderTest.php
```
## Architecture
```
pp-planer/
app/Http/Controllers/ # Inertia controllers (Inertia::render or JSON)
app/Models/ # Eloquent models (factories in database/factories/)
app/Services/ # Business logic (ChurchToolsService, ProExportService, etc.)
app/Jobs/ # Queue jobs (PowerPoint conversion)
app/Mail/ # Mailable classes (German content)
resources/js/Pages/ # Vue page components (mapped via Inertia::render)
resources/js/Components/ # Reusable Vue components
resources/js/Composables/ # Vue composables (useAutoSave)
resources/js/Layouts/ # AuthenticatedLayout, GuestLayout
tests/Feature/ # Pest v4 / PHPUnit feature tests
tests/e2e/ # Playwright browser tests (TypeScript)
propresenter/
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
tests/ # PHPUnit 11 tests with #[Test] attributes
ref/ # .pro fixture files for testing
```
## Code Style -- PHP
- **Formatter**: Laravel Pint (default Laravel preset, no custom config)
- **Imports**: Fully qualified, one per line, alphabetical. App\ first, then Illuminate\, then external.
- **Constructors**: Promoted properties with `private readonly`. Empty body: `{}` on same line.
```php
public function __construct(
private readonly SongMatchingService $songMatchingService,
) {}
```
- **Return types**: Always present. Use union types for multiple returns: `Response|JsonResponse`.
- **String concat**: No spaces around `.` operator: `'Fehler: '.$e->getMessage()`
- **Null safety**: Nullsafe `?->` and null coalescing `??`. Never suppress with `@`.
- **Models**: `$fillable` array (never `$guarded`). `casts()` method (never `$casts` property). Typed relationship returns (`HasMany`, `BelongsTo`). `Attribute::get()` for computed accessors.
- **Migrations**: Anonymous class: `return new class extends Migration {`. Methods: `up(): void`, `down(): void`.
- **Error messages**: German, Du-form. Flash: `->with('success', '...')`. JSON: `'message'` key.
- **Validation**: Inline `$request->validate([...])` with `Rule::in()` for enums.
- **Transactions**: `DB::transaction(function () use (...): void { ... })` for multi-step writes.
- **Constants**: `private const NAME = [...]` for fixed value sets.
## Code Style -- Vue / Frontend
- **Vue 3 Composition API** only. Always `<script setup>`. No Options API.
- **Props**: `defineProps({ name: { type: Type, default: value } })`
- **Emits**: `defineEmits(['kebab-case-event'])`
- **Model**: `defineModel({ type: String, required: true })` for v-model binding.
- **Imports**: `@/` alias for `resources/js/`. Vue from `'vue'`, Inertia from `'@inertiajs/vue3'`.
- **Functions**: Prefer `function name() {}` declarations (not `const name = () => {}`).
- **State**: `ref()` for reactive, `computed()` for derived, `watch()` for side effects.
- **Routing**: `route('name', params)` via Ziggy. `router.post/get/delete` from Inertia with `{ preserveScroll: true }`.
- **Styling**: Tailwind CSS v4 utility classes inline. `<style scoped>` only when necessary (e.g. drag-and-drop).
- **Test IDs**: `data-testid="..."` on interactive elements for Playwright.
- **All UI text**: German, Du-form (not Sie).
## Code Style -- Tests
### PHP Tests (Pest v4 + PHPUnit)
Both styles are used:
- **Function-based Pest**: `test('snake_case german description', function () { ... });` with `beforeEach`
- **Class-based**: `final class NameTest extends TestCase { use RefreshDatabase; public function test_snake_case(): void {} }`
- **Auth**: `$this->actingAs(User::factory()->create())`
- **Vite**: Call `$this->withoutVite()` before Inertia page render tests
- **Time**: `Carbon::setTestNow('2026-03-01 10:00:00')` for deterministic dates
- **Inertia assertions**: `$response->assertInertia(fn ($page) => $page->component('...')->has('...')->where('...'))`
- **DB**: In-memory SQLite (`phpunit.xml`: `DB_CONNECTION=sqlite`, `DB_DATABASE=:memory:`)
- **Helper functions**: Define at file level for shared test setup (e.g. `function makePngUpload(...)`)
- **Storage**: `Storage::fake('public')` in `beforeEach` for file upload tests
### ProPresenter Parser Tests (PHPUnit 11)
- Uses `#[Test]` attribute (not `test_` prefix)
- `setUp()`/`tearDown()` for temp directory management
- Fixtures in `ref/` directory, loaded via `dirname(__DIR__, 2).'/ref/...'`
- Strict mode: `failOnRisky`, `failOnWarning`, `beStrictAboutOutputDuringTests`
### E2E Tests (Playwright)
- TypeScript in `tests/e2e/`, baseURL `https://pp-planer.ddev.site` (HTTPS, `ignoreHTTPSErrors: true` set in `playwright.config.ts`)
- Auth via `auth.setup.ts` (XSRF token + `/dev-login` endpoint, saves state to `.auth/user.json`)
- Selectors: `page.getByTestId('...')`, `page.getByText('...')`, `page.getByRole('...')`
- German text assertions: `await expect(page.getByText('Mit ChurchTools anmelden')).toBeVisible()`
## Key Constraints
- **CTS API is READ-ONLY** -- never write/modify data via ChurchTools API
- **Immediate persistence** -- all user actions save instantly (no separate save button)
- **German locale** -- `APP_LOCALE=de`, all UI text German Du-form
- **File uploads** -- images convert to JPG 1920x1080 (maintain aspect, no crop); PPT/PPTX to multiple JPGs
- **Named routes** -- all routes have names, use `route('name')` everywhere
- **No type suppression** -- never use `as any`, `@ts-ignore`, `@ts-expect-error` in frontend
- **SoftDeletes** -- used on Song and Slide models; use `whereNull('deleted_at')` in manual queries