Compare commits
9 commits
2a68d098a2
...
94170857b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94170857b6 | ||
|
|
2a98f1b30e | ||
|
|
e39d878368 | ||
|
|
cad25d5182 | ||
|
|
36315c67da | ||
|
|
5b9a877607 | ||
|
|
fdabaadb89 | ||
|
|
f2c0c05200 | ||
|
|
27461b7c28 |
|
|
@ -1,8 +1,3 @@
|
|||
ls tests/fixtures/ccli/*.txt | wc -l
|
||||
22
|
||||
|
||||
grep -rl "Strophe\|Refrain" tests/fixtures/ccli/ | wc -l
|
||||
4
|
||||
|
||||
grep -rl "(Repeat)" tests/fixtures/ccli/ | wc -l
|
||||
2
|
||||
22
|
||||
4
|
||||
2
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
ddev exec php artisan test --filter=CcliFixtureSanityTest
|
||||
|
||||
PASS Tests\Feature\CcliFixtureSanityTest
|
||||
✓ ccli fixture corpus has at least 20 txt files
|
||||
✓ each ccli fixture is valid utf8 with section labels and title
|
||||
✓ ccli fixture corpus covers german labels
|
||||
✓ ccli fixture corpus covers repeat markers
|
||||
PASS Tests\Feature\CcliFixtureSanityTest
|
||||
✓ ccli fixture corpus has at least 20 txt files 0.23s
|
||||
✓ each ccli fixture is valid utf8 with section labels and title
|
||||
✓ ccli fixture corpus covers german labels
|
||||
✓ ccli fixture corpus covers repeat markers
|
||||
|
||||
Tests: 4 passed (160 assertions)
|
||||
Duration: 0.35s
|
||||
|
||||
Tests: 4 passed (160 assertions)
|
||||
|
|
|
|||
|
|
@ -33,3 +33,53 @@ ### T7: Global Label Slide Replacement Caveat
|
|||
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
|
||||
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
|
||||
- Missing labels are returned in `unmatched_labels`, and their mapping entries keep empty slide placeholders so `distributed_text` still aligns with the local arrangement shape.
|
||||
|
||||
## Code Quality Review (2026-05-11)
|
||||
|
||||
### Found
|
||||
- **app/Services/CcliPasteParser.php:17-19** — Empty `if` block in constructor.
|
||||
Constructor accepts optional closures (`$sectionDetector`, `$metadataDetector`) but the body
|
||||
contains `if ($sectionDetector !== null || $metadataDetector !== null) { }` with no statements.
|
||||
Dead code — same category as forbidden empty `catch` blocks. Remove the entire `if` statement.
|
||||
|
||||
### Passed
|
||||
- Pint: 9 files clean (after fixing `-rw-------` perms on DTO + Service files)
|
||||
- Tests: 503 passed, 0 failed
|
||||
- Build: vite production build clean
|
||||
- No `as any`, `@ts-ignore`, `console.log` in Vue
|
||||
- No `@` error suppression in PHP
|
||||
- German Du-form throughout (no `Sie`/`Ihre`)
|
||||
- All `final` + `readonly` correct
|
||||
- data-testid coverage complete on interactive Vue elements
|
||||
|
||||
### Side note — file permissions
|
||||
Several new PHP files were created with `-rw-------` (owner-only). DDEV web container could not
|
||||
read them, breaking Pint. Fixed by `chmod 644`. Likely an editor/umask issue worth investigating
|
||||
in the dev environment to avoid recurrence.
|
||||
|
||||
### Resolution (2026-05-11 re-review)
|
||||
- Empty `if` block in `CcliPasteParser.php` removed; constructor is now `) {}`.
|
||||
- Pint PASS on the file.
|
||||
- Tests: 503 passed.
|
||||
- Build: clean.
|
||||
- Vue: no forbidden patterns.
|
||||
- Final verdict: APPROVE.
|
||||
|
||||
## QA Finding (2026-05-11): API auth broken via DDEV URL
|
||||
|
||||
**Root cause**: `APP_URL=http://pp-planer.test` in `.env`, but DDEV serves on `https://pp-planer.ddev.site`.
|
||||
`config/sanctum.php` derives `SANCTUM_STATEFUL_DOMAINS` from `APP_URL` host → list contains `pp-planer.test` only.
|
||||
Cookie-based auth on `/api/*` routes (`auth:sanctum` middleware) fails when accessed via `pp-planer.ddev.site`.
|
||||
|
||||
**Symptom**:
|
||||
- `/api/ccli/preview` with `Accept: application/json` → `401 Unauthenticated`
|
||||
- `/api/ccli/preview` with default `Accept: */*` (Vue dialog's fetch) → 200 HTML (auth redirect), then `res.json()` throws "Unexpected token '<'" → caught → dialog shows "Netzwerkfehler. Bitte versuche es erneut."
|
||||
- E2E tests (`ccli-paste-import.spec.ts` tests 6+7) skip silently via `test.skip()` guards when API fails, masking this in CI.
|
||||
|
||||
**Fix**: Add to `.env` and `.env.example`:
|
||||
```
|
||||
SANCTUM_STATEFUL_DOMAINS=pp-planer.ddev.site,pp-planer.test,localhost,127.0.0.1
|
||||
```
|
||||
Or change `APP_URL` to `https://pp-planer.ddev.site` if DDEV is the canonical dev URL.
|
||||
|
||||
**Bookmarklet has matching issue**: `BookmarkletController` uses `Config::get('app.url')` → bookmarklet opens `http://pp-planer.test/songs/import-from-ccli-paste?prefill=...` instead of the ddev URL.
|
||||
|
|
|
|||
|
|
@ -108,3 +108,25 @@ ### 2026-05-11 CcliPasteController (T10)
|
|||
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
|
||||
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
|
||||
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.
|
||||
|
||||
## 2026-05-11 Plan Compliance Audit
|
||||
|
||||
- CCLI SongSelect Import audit approved: 20/20 must-have checks present, 4/4 forbidden-pattern checks clean.
|
||||
- Targeted PHP filter run passed: 140 tests, 807 assertions; npm build and Pint check also passed.
|
||||
- Registered routes verified: api.ccli.preview, api.ccli.import, bookmarklets.ccli, songs.import-from-ccli-paste.
|
||||
|
||||
## QA Re-verification (2026-05-11): Sanctum fix confirmed
|
||||
|
||||
After applying `pp-planer.ddev.site` to the default stateful domains in `config/sanctum.php` and clearing config cache:
|
||||
|
||||
- `sanctum.stateful` resolves to `[pp-planer.test, pp-planer.ddev.site, localhost, localhost:8000, 127.0.0.1, 127.0.0.1:8000, ::1]` ✓
|
||||
- `/api/ccli/preview` returns 200 JSON when called from an authenticated DDEV browser session (was 401 before)
|
||||
- End-to-end Vue dialog flow on `/songs`: paste lyrics → click "Vorschau" → preview metadata (CCLI-Nr, sections) renders. No "Netzwerkfehler" message.
|
||||
- E2E test `paste fixture and preview shows metadata` now PASSES (was silently skipped before the fix).
|
||||
- Suite result: 11 passed, 3 skipped (vs 10 passed, 4 skipped pre-fix). Net +1 test exercising the API endpoint.
|
||||
|
||||
Remaining skipped tests (3):
|
||||
- `duplicate import shows error with edit link` — guarded skip when initial create import returns non-201. Likely a separate data-state or status-code issue (worth investigating but unrelated to the Sanctum fix).
|
||||
- 2× CCLI translation pairing tests — were skipped before the fix too; not regressions.
|
||||
|
||||
Bookmarklet endpoint still serves the same JS payload, header `Content-Type: text/javascript`, body starts with `javascript:`. Note: the embedded `APP_URL` is still `http://pp-planer.test` (driven by `config('app.url')`), so a DDEV user clicking the bookmarklet on SongSelect still lands on the wrong host. That's an APP_URL / bookmarklet-config concern, not a Sanctum concern.
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -20,7 +20,7 @@ ## General
|
|||
- 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
|
||||
- ProPresenter `.pro` file parser/generator is implemented as a separate composer package (`bussnet/propresenter7-php-lib`) linked via a composer vcs repository
|
||||
|
||||
## The Plan
|
||||
|
||||
|
|
@ -239,14 +239,14 @@ ### Routes
|
|||
|
||||
## Repository Structure
|
||||
|
||||
Two git repositories, both local (no remote):
|
||||
Two git repositories, both on their `master` branch, each with a git remote. `pp-planer` has one SSH remote (`origin` → `ssh://git@git.stadtmission-butzbach.de:2222/public/pp-planer.git`); `propresenter` has two HTTPS mirrors (`forgejo` → `https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git`, `origin` → `https://github.com/bussnet/propresenter7-php-lib.git`):
|
||||
|
||||
| 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) |
|
||||
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `master` | Laravel app (main codebase) |
|
||||
| **propresenter** | `/Users/thorsten/AI/propresenter` | `master` | ProPresenter .pro/.proplaylist parser (composer vcs dependency) |
|
||||
|
||||
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
|
||||
The parser is consumed via a `composer.json` vcs repository: `"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git"` (a local `"../propresenter"` path repository can be enabled for development via `use_local_pp_lib.sh`).
|
||||
|
||||
## Build, Test, Lint Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExportProFileController extends Controller
|
||||
|
|
@ -26,7 +27,13 @@ public function store(Request $request): JsonResponse
|
|||
$created = [];
|
||||
|
||||
foreach ($request->file('files') as $file) {
|
||||
$storedPath = $file->store('export-pro-files/'.$type, 'local');
|
||||
// Store under a random basename but keep the REAL client extension.
|
||||
// Laravel's store() names files by the guessed MIME extension, which
|
||||
// turns a .probundle (a ZIP) into a .zip and breaks extension-based
|
||||
// routing at export time.
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
$basename = Str::random(40).($extension !== '' ? '.'.$extension : '');
|
||||
$storedPath = $file->storeAs('export-pro-files/'.$type, $basename, 'local');
|
||||
$maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0;
|
||||
|
||||
$record = ExportProFile::create([
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
use ProPresenter\Parser\ProFileReader;
|
||||
use ProPresenter\Parser\ProFileWriter;
|
||||
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||
use ProPresenter\Parser\Zip64Fixer;
|
||||
use ZipArchive;
|
||||
|
|
@ -95,6 +97,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$embeddedFiles,
|
||||
$service,
|
||||
'information',
|
||||
'information',
|
||||
);
|
||||
$announcementInserted = true;
|
||||
}
|
||||
|
|
@ -137,7 +140,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$skippedUnmatched++;
|
||||
}
|
||||
} else {
|
||||
$isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon';
|
||||
$partType = $this->backgroundPartTypeForAgendaItem($item);
|
||||
$isSermon = $partType === 'sermon';
|
||||
|
||||
if ($isSermon) {
|
||||
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
|
|
@ -153,7 +157,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
$service,
|
||||
$this->backgroundPartTypeForAgendaItem($item),
|
||||
$partType,
|
||||
$partType,
|
||||
);
|
||||
|
||||
if (count($playlistItems) > $countBefore) {
|
||||
|
|
@ -171,7 +176,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
$service,
|
||||
$this->backgroundPartTypeForAgendaItem($item),
|
||||
$partType,
|
||||
$partType,
|
||||
);
|
||||
|
||||
if (count($playlistItems) > $countBefore) {
|
||||
|
|
@ -203,7 +209,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
}
|
||||
|
||||
if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) {
|
||||
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles);
|
||||
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles, $warnings);
|
||||
if ($keywordItem !== null) {
|
||||
$playlistItems[] = $keywordItem;
|
||||
}
|
||||
|
|
@ -223,6 +229,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$prependFiles,
|
||||
$service,
|
||||
'information',
|
||||
'information',
|
||||
);
|
||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
||||
|
|
@ -233,7 +240,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||
}
|
||||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);
|
||||
|
||||
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
|
||||
|
|
@ -360,7 +367,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
|||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||
}
|
||||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);
|
||||
|
||||
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
|
||||
|
|
@ -398,6 +405,7 @@ private function addSlidesFromCollection(
|
|||
array &$embeddedFiles,
|
||||
?Service $service = null,
|
||||
?string $backgroundPartType = null,
|
||||
?string $macroPartType = null,
|
||||
): void {
|
||||
$slideDataList = [];
|
||||
$imageFiles = [];
|
||||
|
|
@ -440,6 +448,23 @@ private function addSlidesFromCollection(
|
|||
return;
|
||||
}
|
||||
|
||||
if ($service !== null && $macroPartType !== null) {
|
||||
$total = count($slideDataList);
|
||||
foreach ($slideDataList as $i => &$slideData) {
|
||||
$macros = $this->macroResolutionService->macrosForSlide(
|
||||
$service,
|
||||
$macroPartType,
|
||||
['index' => $i, 'total' => $total, 'label_id' => null],
|
||||
);
|
||||
|
||||
if (! empty($macros)) {
|
||||
// ProPresenter parser currently supports one `macro` entry per slide
|
||||
$slideData['macro'] = $macros[0];
|
||||
}
|
||||
}
|
||||
unset($slideData);
|
||||
}
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'name' => $label,
|
||||
|
|
@ -516,6 +541,7 @@ private function addSlidePresentation(
|
|||
$embeddedFiles,
|
||||
$service,
|
||||
$type,
|
||||
$type,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -580,28 +606,30 @@ private function addHeadlineItem(
|
|||
];
|
||||
}
|
||||
|
||||
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
|
||||
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, array &$warnings): void
|
||||
{
|
||||
$prefixItems = $this->buildExportProItems(
|
||||
ExportProFile::prefix()->orderBy('order')->get(),
|
||||
$embeddedFiles,
|
||||
$warnings,
|
||||
);
|
||||
|
||||
$postfixItems = $this->buildExportProItems(
|
||||
ExportProFile::postfix()->orderBy('order')->get(),
|
||||
$embeddedFiles,
|
||||
$warnings,
|
||||
);
|
||||
|
||||
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
|
||||
}
|
||||
|
||||
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
|
||||
private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded): array
|
||||
private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded, array &$warnings): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$item = $this->embedExportProFile($file, $embedded);
|
||||
$item = $this->embedExportProFile($file, $embedded, $warnings);
|
||||
if ($item !== null) {
|
||||
$items[] = $item;
|
||||
}
|
||||
|
|
@ -613,52 +641,74 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, arra
|
|||
/**
|
||||
* Read a single export .pro/.probundle file from the 'local' disk, embed its
|
||||
* bytes (and any .probundle media) into $embeddedFiles, and return the matching
|
||||
* playlist presentation item. Returns null when the file is missing or a
|
||||
* .probundle has no inner .pro entry.
|
||||
* playlist presentation item. Returns null (and records a German warning) when
|
||||
* the file is missing, a .probundle has no inner .pro entry, or the embedded
|
||||
* .pro parses to zero content slides.
|
||||
*
|
||||
* The file type is derived from the ORIGINAL (already-validated) client
|
||||
* filename, NOT from the stored path: Laravel's store() names uploads by the
|
||||
* guessed MIME extension, so a .probundle (a ZIP) is persisted as .zip.
|
||||
* Branching on the stored path would then miss the probundle branch and embed
|
||||
* the raw ZIP bytes as a broken .pro (ProPresenter shows an empty slide).
|
||||
*
|
||||
* The embedded .pro name uses the uppercased type as marker
|
||||
* (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
|
||||
*
|
||||
* @return array{type: string, name: string, path: string}|null
|
||||
*/
|
||||
private function embedExportProFile(ExportProFile $file, array &$embeddedFiles): ?array
|
||||
private function embedExportProFile(ExportProFile $file, array &$embeddedFiles, array &$warnings): ?array
|
||||
{
|
||||
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION));
|
||||
$ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION));
|
||||
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||
$embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
|
||||
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
|
||||
|
||||
if ($ext === 'probundle') {
|
||||
// Treat both .probundle and .zip as bundles: a .probundle is a ZIP, and
|
||||
// mis-stored uploads may end up with a .zip extension.
|
||||
$isBundle = $ext === 'probundle' || $ext === 'zip';
|
||||
$mediaFiles = [];
|
||||
|
||||
if ($isBundle) {
|
||||
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
|
||||
|
||||
$extracted = $this->extractProBundle($storedAbsPath);
|
||||
if ($extracted === null) {
|
||||
$warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[$proBytes, $mediaFiles] = $extracted;
|
||||
|
||||
foreach ($mediaFiles as $entryName => $entryBytes) {
|
||||
$mediaName = basename((string) $entryName);
|
||||
if (! isset($embeddedFiles[$mediaName])) {
|
||||
$embeddedFiles[$mediaName] = $entryBytes;
|
||||
}
|
||||
}
|
||||
|
||||
$embeddedFiles[$embeddedProName] = $proBytes;
|
||||
} else {
|
||||
$bytes = Storage::disk('local')->get($file->stored_path);
|
||||
if ($bytes === null) {
|
||||
$proBytes = Storage::disk('local')->get($file->stored_path);
|
||||
if ($proBytes === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$embeddedFiles[$embeddedProName] = $bytes;
|
||||
}
|
||||
|
||||
// Fail safe: a contentless export .pro would render an empty slide in
|
||||
// ProPresenter. Skip it with a warning when it parses to zero content
|
||||
// slides. Bytes that cannot be parsed at all (unverifiable) are embedded
|
||||
// verbatim to preserve the legacy pass-through behaviour.
|
||||
if ($this->contentSlideCountFromProBytes($proBytes) === 0) {
|
||||
$warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($mediaFiles as $entryName => $entryBytes) {
|
||||
$mediaName = basename((string) $entryName);
|
||||
if (! isset($embeddedFiles[$mediaName])) {
|
||||
$embeddedFiles[$mediaName] = $entryBytes;
|
||||
}
|
||||
}
|
||||
|
||||
$embeddedFiles[$embeddedProName] = $proBytes;
|
||||
|
||||
return [
|
||||
'type' => 'presentation',
|
||||
'name' => $displayName,
|
||||
|
|
@ -666,6 +716,42 @@ private function embedExportProFile(ExportProFile $file, array &$embeddedFiles):
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count content slides across all groups of an embedded .pro payload. Returns
|
||||
* null when the bytes cannot be parsed as a ProPresenter presentation, so the
|
||||
* caller can distinguish "verified empty" (0) from "unverifiable" (null).
|
||||
*/
|
||||
private function contentSlideCountFromProBytes(string $proBytes): ?int
|
||||
{
|
||||
if ($proBytes === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'export-pro-verify-');
|
||||
if ($tempPath === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (@file_put_contents($tempPath, $proBytes) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$song = ProFileReader::read($tempPath);
|
||||
|
||||
$count = 0;
|
||||
foreach ($song->getGroups() as $group) {
|
||||
$count += count($song->getSlidesForGroup($group));
|
||||
}
|
||||
|
||||
return $count;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
} finally {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
|
||||
*
|
||||
|
|
@ -752,7 +838,12 @@ private function extractProBundle(string $absPath): ?array
|
|||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||
{
|
||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements, [], $options);
|
||||
// Split generate → inject → write: convert FOREGROUND (uploaded) image media
|
||||
// actions into content fill.media ELEMENTS; BACKGROUND actions (service
|
||||
// background / key-visual) stay as background-layer media actions.
|
||||
$song = ProFileGenerator::generate($name, $groups, $arrangements, [], $options);
|
||||
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
|
||||
ProFileWriter::write($song, $path);
|
||||
}
|
||||
|
||||
private function buildModeratorSlideData(Service $service): ?array
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class ProBundleExportService
|
|||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
private readonly ServiceImageResolver $imageResolver,
|
||||
private readonly SlideMediaElementBuilder $slideMediaElementBuilder,
|
||||
) {}
|
||||
|
||||
public function generateBundle(Service $service, string $blockType): string
|
||||
|
|
@ -163,6 +164,12 @@ private function buildBundleFromSlides(
|
|||
];
|
||||
|
||||
$song = ProFileGenerator::generate($groupName, $groups, $arrangements);
|
||||
|
||||
// Convert FOREGROUND (uploaded) image media actions into content fill.media
|
||||
// ELEMENTS; the BACKGROUND service-image action is left untouched as a
|
||||
// background-layer media action beneath the uploaded content.
|
||||
$this->slideMediaElementBuilder->replaceImageActionsWithElements($song);
|
||||
|
||||
$proFilename = self::safeFilename($groupName).'.pro';
|
||||
|
||||
$bundle = new PresentationBundle($song, $proFilename, $mediaFiles);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class ProExportService
|
|||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
private readonly ServiceImageResolver $imageResolver,
|
||||
private readonly SlideMediaElementBuilder $slideMediaElementBuilder,
|
||||
) {}
|
||||
|
||||
public function generateProFile(Song $song, ?Service $service = null, ?SongArrangement $selected = null): string
|
||||
|
|
@ -36,6 +37,11 @@ public function generateParserSong(Song $song, ?Service $service = null, ?SongAr
|
|||
$this->buildCcliMetadata($song),
|
||||
);
|
||||
|
||||
// Convert FOREGROUND (uploaded) image media actions into content fill.media
|
||||
// elements; the BACKGROUND service-image action is left untouched so it stays
|
||||
// a background layer beneath the song text.
|
||||
$this->slideMediaElementBuilder->replaceImageActionsWithElements($parserSong);
|
||||
|
||||
// Pre-select the chosen arrangement (protobuf field 10) by name so the export honours the
|
||||
// per-service selection while keeping ALL arrangements available in the .pro file.
|
||||
$chosen = $this->chosenArrangement($song, $selected);
|
||||
|
|
|
|||
352
app/Services/SlideMediaElementBuilder.php
Normal file
352
app/Services/SlideMediaElementBuilder.php
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use ProPresenter\Parser\Song;
|
||||
use Rv\Data\Action\ActionType;
|
||||
use Rv\Data\Action\LayerType;
|
||||
use Rv\Data\AlphaType;
|
||||
use Rv\Data\Cue;
|
||||
use Rv\Data\FileProperties;
|
||||
use Rv\Data\Graphics\Element as GraphicsElement;
|
||||
use Rv\Data\Graphics\Fill;
|
||||
use Rv\Data\Graphics\Path;
|
||||
use Rv\Data\Graphics\Path\BezierPoint;
|
||||
use Rv\Data\Graphics\Path\Shape;
|
||||
use Rv\Data\Graphics\Path\Shape\Type as ShapeType;
|
||||
use Rv\Data\Graphics\Point;
|
||||
use Rv\Data\Graphics\Rect;
|
||||
use Rv\Data\Graphics\Size;
|
||||
use Rv\Data\Media;
|
||||
use Rv\Data\Media\DrawingProperties;
|
||||
use Rv\Data\Media\ImageTypeProperties;
|
||||
use Rv\Data\Media\Metadata;
|
||||
use Rv\Data\Media\ScaleBehavior;
|
||||
use Rv\Data\Slide as BaseSlide;
|
||||
use Rv\Data\Slide\Element as SlideElement;
|
||||
use Rv\Data\URL;
|
||||
use Rv\Data\URL\LocalRelativePath;
|
||||
use Rv\Data\URL\Platform as UrlPlatform;
|
||||
use Rv\Data\UUID;
|
||||
|
||||
/**
|
||||
* Converts a generated Song's FOREGROUND image media ACTIONS (uploaded slide
|
||||
* images from the Information/Moderation/Sermon blocks and the abspann info
|
||||
* images) into full-frame slide-content fill.media ELEMENTS, and strips those
|
||||
* foreground actions so none survives.
|
||||
*
|
||||
* BACKGROUND-layer image media actions — the service background image AND the
|
||||
* key-visual — are LEFT UNTOUCHED. They stay as media ACTIONS on the BACKGROUND
|
||||
* layer so ProPresenter renders them behind the whole slide-content stack. Text,
|
||||
* translation, subtitle, macro and completion actions are never touched.
|
||||
*
|
||||
* Combined rule (content vs background):
|
||||
*
|
||||
* - Background image / key-visual → media ACTION, layer BACKGROUND (kept).
|
||||
* - Uploaded content slide image → fill.media content ELEMENT (converted).
|
||||
* - Songtext / name-tag text → text content elements (kept).
|
||||
*
|
||||
* Layering contract for the resulting base-slide element array (index 0 = bottom):
|
||||
*
|
||||
* [0 .. n-1] text elements from ProFileGenerator::generate()
|
||||
* [n] uploaded (foreground) image element, appended on top
|
||||
*
|
||||
* The background image is NOT an element; it is a media action on the BACKGROUND
|
||||
* layer and renders beneath the entire element stack automatically.
|
||||
*
|
||||
* Every converted foreground image is referenced bundle-relative
|
||||
* (ROOT_CURRENT_RESOURCE) by its bare filename so it resolves against the bytes
|
||||
* embedded into the .pro/.probundle archive — never by an absolute path.
|
||||
*/
|
||||
class SlideMediaElementBuilder
|
||||
{
|
||||
/**
|
||||
* Build a full-frame (0,0 → 1920×1080) content element whose fill is the
|
||||
* given bundle-relative image.
|
||||
*
|
||||
* Images are pre-sized to 1920×1080 (16:9) before export, so FILL and FIT are
|
||||
* equivalent; FILL is used as the default scale behaviour.
|
||||
*/
|
||||
public function mediaFillElement(
|
||||
string $bundleRelativeFilename,
|
||||
string $elementName = '',
|
||||
int $width = 1920,
|
||||
int $height = 1080,
|
||||
int $scaleBehavior = ScaleBehavior::SCALE_BEHAVIOR_FILL,
|
||||
): SlideElement {
|
||||
$naturalSize = new Size;
|
||||
$naturalSize->setWidth($width);
|
||||
$naturalSize->setHeight($height);
|
||||
|
||||
$drawing = new DrawingProperties;
|
||||
$drawing->setScaleBehavior($scaleBehavior);
|
||||
$drawing->setNaturalSize($naturalSize);
|
||||
$drawing->setAlphaType(AlphaType::ALPHA_TYPE_STRAIGHT);
|
||||
|
||||
$fileProperties = new FileProperties;
|
||||
$fileProperties->setLocalUrl($this->bundleRelativeUrl($bundleRelativeFilename));
|
||||
|
||||
$image = new ImageTypeProperties;
|
||||
$image->setDrawing($drawing);
|
||||
$image->setFile($fileProperties);
|
||||
|
||||
$metadata = new Metadata;
|
||||
$metadata->setFormat('JPG');
|
||||
|
||||
$media = new Media;
|
||||
$media->setUuid($this->newUuid());
|
||||
$media->setUrl($this->bundleRelativeUrl($bundleRelativeFilename));
|
||||
$media->setMetadata($metadata);
|
||||
$media->setImage($image);
|
||||
|
||||
$fill = new Fill;
|
||||
$fill->setEnable(true);
|
||||
$fill->setMedia($media);
|
||||
|
||||
$graphicsElement = new GraphicsElement;
|
||||
$graphicsElement->setUuid($this->newUuid());
|
||||
$graphicsElement->setName($elementName);
|
||||
$graphicsElement->setBounds($this->fullFrameBounds($width, $height));
|
||||
$graphicsElement->setOpacity(1.0);
|
||||
$graphicsElement->setPath($this->rectanglePath());
|
||||
$graphicsElement->setFill($fill);
|
||||
|
||||
$slideElement = new SlideElement;
|
||||
$slideElement->setElement($graphicsElement);
|
||||
$slideElement->setInfo(0);
|
||||
|
||||
return $slideElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained REPLACE: derive the per-cue plan from the Song's own
|
||||
* FOREGROUND image media actions, then strip those actions and inject content
|
||||
* elements. This is the method the export services call after
|
||||
* ProFileGenerator::generate(). BACKGROUND image actions are left in place.
|
||||
*/
|
||||
public function replaceImageActionsWithElements(Song $song): void
|
||||
{
|
||||
$this->injectIntoSong($song, $this->planFromSong($song));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert each cue's FOREGROUND (uploaded) image media action into a
|
||||
* full-frame content fill.media element appended on top of the generated
|
||||
* text/content elements, and strip that foreground action. The BACKGROUND
|
||||
* media action (service background / key-visual) is left untouched so it
|
||||
* keeps rendering as a background layer beneath all slide content.
|
||||
*
|
||||
* $plan is an ordered list (one entry per cue, in cue order). Each entry:
|
||||
* ['foreground' => ?string, 'foregroundName' => ?string]
|
||||
* A null/absent foreground leaves the cue exactly as generated (text-only
|
||||
* songs, key-visual-only slides, name-tag slides).
|
||||
*
|
||||
* @param array<int, array{foreground?: ?string, foregroundName?: ?string}> $plan
|
||||
*/
|
||||
public function injectIntoSong(Song $song, array $plan): void
|
||||
{
|
||||
$index = 0;
|
||||
|
||||
foreach ($song->getPresentation()->getCues() as $cue) {
|
||||
$entry = $plan[$index] ?? [];
|
||||
$index++;
|
||||
|
||||
$baseSlide = $this->baseSlideForCue($cue);
|
||||
if ($baseSlide === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$foreground = $entry['foreground'] ?? null;
|
||||
|
||||
if ($foreground === null || $foreground === '') {
|
||||
// No foreground image: leave the cue (text elements, BACKGROUND
|
||||
// media action, macro) exactly as the generator produced it.
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->stripForegroundImageMediaActions($cue);
|
||||
|
||||
$elements = [];
|
||||
foreach ($baseSlide->getElements() as $element) {
|
||||
$elements[] = $element;
|
||||
}
|
||||
|
||||
$elements[] = $this->mediaFillElement($foreground, $entry['foregroundName'] ?? 'Bild');
|
||||
|
||||
$baseSlide->setElements($elements);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the injection plan from the FOREGROUND image media actions the
|
||||
* generator added. BACKGROUND-layer image actions (service background /
|
||||
* key-visual) are intentionally ignored so they remain media actions.
|
||||
*
|
||||
* @return array<int, array{foreground: ?string, foregroundName: ?string}>
|
||||
*/
|
||||
private function planFromSong(Song $song): array
|
||||
{
|
||||
$plan = [];
|
||||
|
||||
foreach ($song->getPresentation()->getCues() as $cue) {
|
||||
$foreground = null;
|
||||
$foregroundName = null;
|
||||
|
||||
foreach ($cue->getActions() as $action) {
|
||||
if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mediaType = $action->getMedia();
|
||||
$mediaElement = $mediaType?->getElement();
|
||||
if ($mediaElement === null || ! $mediaElement->hasImage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only FOREGROUND images become content elements. BACKGROUND-layer
|
||||
// images (service background / key-visual) stay as media actions.
|
||||
if ($mediaType->getLayerType() === LayerType::LAYER_TYPE_BACKGROUND) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = $mediaElement->getUrl()?->getAbsoluteString();
|
||||
if ($filename === null || $filename === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$foreground = $filename;
|
||||
$foregroundName = $action->getName() !== '' ? $action->getName() : null;
|
||||
}
|
||||
|
||||
$plan[] = [
|
||||
'foreground' => $foreground,
|
||||
'foregroundName' => $foregroundName,
|
||||
];
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** Resolve the base slide via Cue → SLIDE action → presentation → base slide. */
|
||||
private function baseSlideForCue(Cue $cue): ?BaseSlide
|
||||
{
|
||||
foreach ($cue->getActions() as $action) {
|
||||
if ($action->getType() === ActionType::ACTION_TYPE_PRESENTATION_SLIDE) {
|
||||
return $action->getSlide()?->getPresentation()?->getBaseSlide();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove only FOREGROUND-layer media actions whose element carries an image.
|
||||
* BACKGROUND-layer image actions (service background / key-visual) are kept.
|
||||
*/
|
||||
private function stripForegroundImageMediaActions(Cue $cue): void
|
||||
{
|
||||
$kept = [];
|
||||
$stripped = false;
|
||||
|
||||
foreach ($cue->getActions() as $action) {
|
||||
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) {
|
||||
$mediaType = $action->getMedia();
|
||||
$mediaElement = $mediaType?->getElement();
|
||||
if (
|
||||
$mediaElement !== null
|
||||
&& $mediaElement->hasImage()
|
||||
&& $mediaType->getLayerType() !== LayerType::LAYER_TYPE_BACKGROUND
|
||||
) {
|
||||
$stripped = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$kept[] = $action;
|
||||
}
|
||||
|
||||
if ($stripped) {
|
||||
$cue->setActions($kept);
|
||||
}
|
||||
}
|
||||
|
||||
private function bundleRelativeUrl(string $filename): URL
|
||||
{
|
||||
$local = new LocalRelativePath;
|
||||
$local->setRoot(LocalRelativePath\Root::ROOT_CURRENT_RESOURCE);
|
||||
$local->setPath($filename);
|
||||
|
||||
$url = new URL;
|
||||
$url->setAbsoluteString($filename);
|
||||
$url->setLocal($local);
|
||||
$url->setPlatform(UrlPlatform::PLATFORM_MACOS);
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function fullFrameBounds(int $width, int $height): Rect
|
||||
{
|
||||
$origin = new Point;
|
||||
$origin->setX(0);
|
||||
$origin->setY(0);
|
||||
|
||||
$size = new Size;
|
||||
$size->setWidth($width);
|
||||
$size->setHeight($height);
|
||||
|
||||
$rect = new Rect;
|
||||
$rect->setOrigin($origin);
|
||||
$rect->setSize($size);
|
||||
|
||||
return $rect;
|
||||
}
|
||||
|
||||
private function rectanglePath(): Path
|
||||
{
|
||||
$points = [];
|
||||
foreach ([[0, 0], [1, 0], [1, 1], [0, 1]] as [$x, $y]) {
|
||||
$point = new Point;
|
||||
$point->setX((float) $x);
|
||||
$point->setY((float) $y);
|
||||
|
||||
$bezier = new BezierPoint;
|
||||
$bezier->setPoint($point);
|
||||
$bezier->setQ0($point);
|
||||
$bezier->setQ1($point);
|
||||
$bezier->setCurved(false);
|
||||
|
||||
$points[] = $bezier;
|
||||
}
|
||||
|
||||
$path = new Path;
|
||||
$path->setClosed(true);
|
||||
$path->setPoints($points);
|
||||
|
||||
$shape = new Shape;
|
||||
$shape->setType(ShapeType::TYPE_RECTANGLE);
|
||||
$path->setShape($shape);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function newUuid(): UUID
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);
|
||||
$hex = bin2hex($bytes);
|
||||
|
||||
$uuid = new UUID;
|
||||
$uuid->setString(strtoupper(sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
substr($hex, 0, 8),
|
||||
substr($hex, 8, 4),
|
||||
substr($hex, 12, 4),
|
||||
substr($hex, 16, 4),
|
||||
substr($hex, 20, 12),
|
||||
)));
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git"
|
||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"laravel/sanctum": "^4.0",
|
||||
"laravel/socialite": "^5.24",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"propresenter/parser": "dev-master",
|
||||
"bussnet/propresenter7-php-lib": "dev-master",
|
||||
"spatie/pdf-to-image": "^1.2",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
|
|
|
|||
102
composer.lock
generated
102
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "87837501106e784aa10ddd7743056cba",
|
||||
"content-hash": "8ddc03457b69ce825f98a92c19a0204e",
|
||||
"packages": [
|
||||
{
|
||||
"name": "5pm-hdh/churchtools-api",
|
||||
|
|
@ -191,6 +191,77 @@
|
|||
],
|
||||
"time": "2026-02-10T14:33:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bussnet/propresenter7-php-lib",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git",
|
||||
"reference": "01654209c699deaea267c9e11e95d97e79505234"
|
||||
},
|
||||
"require": {
|
||||
"google/protobuf": "^4.0",
|
||||
"php": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"bin": [
|
||||
"bin/parse-song.php",
|
||||
"bin/parse-playlist.php"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ProPresenter\\Parser\\": "src/",
|
||||
"Rv\\Data\\": "generated/Rv/Data/",
|
||||
"GPBMetadata\\": "generated/GPBMetadata/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"ProPresenter\\Parser\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": [
|
||||
"phpunit"
|
||||
],
|
||||
"test:coverage": [
|
||||
"phpunit --coverage-text"
|
||||
]
|
||||
},
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Thorsten Buss",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP library to read, modify, and generate ProPresenter 7 files (.pro songs, .proplaylist, .probundle, themes, and global library files).",
|
||||
"homepage": "https://github.com/bussnet/propresenter7-php-lib",
|
||||
"keywords": [
|
||||
"ccli",
|
||||
"church",
|
||||
"parser",
|
||||
"presentation",
|
||||
"pro-file",
|
||||
"probundle",
|
||||
"proplaylist",
|
||||
"propresenter",
|
||||
"propresenter7",
|
||||
"protobuf",
|
||||
"worship"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/bussnet/propresenter7-php-lib/issues",
|
||||
"source": "https://github.com/bussnet/propresenter7-php-lib"
|
||||
},
|
||||
"time": "2026-07-05T21:05:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "carbonphp/carbon-doctrine-types",
|
||||
"version": "3.2.0",
|
||||
|
|
@ -3813,33 +3884,6 @@
|
|||
],
|
||||
"time": "2026-01-27T09:17:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "propresenter/parser",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git",
|
||||
"reference": "9e3e719806d8db3941444b8424fdd56b3b534aa8"
|
||||
},
|
||||
"require": {
|
||||
"google/protobuf": "^4.0",
|
||||
"php": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ProPresenter\\Parser\\": "src/",
|
||||
"Rv\\Data\\": "generated/Rv/Data/",
|
||||
"GPBMetadata\\": "generated/GPBMetadata/"
|
||||
}
|
||||
},
|
||||
"description": "ProPresenter song file parser",
|
||||
"time": "2026-05-03T19:40:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -10862,7 +10906,7 @@
|
|||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {
|
||||
"propresenter/parser": 20
|
||||
"bussnet/propresenter7-php-lib": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
|
|
|
|||
851
package-lock.json
generated
851
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,10 +14,14 @@
|
|||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Rv\Data\Cue\CompletionActionType;
|
||||
use Rv\Data\Cue\CompletionTargetType;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AbspannExportTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -53,31 +57,62 @@ public function test_abspann_is_last_item_with_keyvisual_first_then_information_
|
|||
$slides = $this->allParserSlides($abspann);
|
||||
$this->assertCount(3, $slides);
|
||||
|
||||
// First cue is the key-visual (image-only background media).
|
||||
// First cue is the key-visual as a BACKGROUND media action (no content element).
|
||||
$this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual');
|
||||
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
|
||||
$this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media');
|
||||
$this->assertSame([], $this->fillMediaUrls($slides[0]), 'Key-visual cue has no content image element');
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slides[0]), 'No foreground media action may remain');
|
||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
// Remaining cues are the information slides (foreground media, labelled by filename).
|
||||
$this->assertTrue($slides[1]->hasMedia());
|
||||
// Remaining cues are the information slides as content image elements, labelled by filename.
|
||||
$this->assertNotEmpty($this->fillMediaUrls($slides[1]));
|
||||
$this->assertNotContains('KEY_VISUAL.jpg', $this->fillMediaUrls($slides[1]));
|
||||
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
||||
$this->assertFalse($this->hasImageMediaAction($slides[1]));
|
||||
$this->assertSame('info1.jpg', $slides[1]->getLabel());
|
||||
$this->assertTrue($slides[2]->hasMedia());
|
||||
$this->assertNotEmpty($this->fillMediaUrls($slides[2]));
|
||||
$this->assertSame('info2.jpg', $slides[2]->getLabel());
|
||||
|
||||
// The Abspann is a self-advancing loop on the live parser round-trip: every cue
|
||||
// auto-advances after 10s and the last cue jumps back to the first ("auto repeat all").
|
||||
$presentation = $abspann->getPresentation();
|
||||
$cues = $presentation->getCues();
|
||||
$this->assertCount(3, $cues);
|
||||
|
||||
foreach ($cues as $cue) {
|
||||
$this->assertSame(
|
||||
CompletionActionType::COMPLETION_ACTION_TYPE_AFTER_TIME,
|
||||
$cue->getCompletionActionType(),
|
||||
'Every Abspann cue must auto-advance after a fixed time',
|
||||
);
|
||||
$this->assertSame(10.0, $cue->getCompletionTime(), 'Auto-advance must fire after 10 seconds');
|
||||
}
|
||||
|
||||
// Leading cues step to the next slide; the final cue loops back to the first.
|
||||
$this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[0]->getCompletionTargetType());
|
||||
$this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[1]->getCompletionTargetType());
|
||||
$this->assertSame(
|
||||
CompletionTargetType::COMPLETION_TARGET_TYPE_FIRST,
|
||||
$cues[count($cues) - 1]->getCompletionTargetType(),
|
||||
'The final Abspann cue must loop back to the first cue',
|
||||
);
|
||||
|
||||
// The presentation carries the Dissolve transition (soft cross-fade between slides).
|
||||
$this->assertTrue($presentation->hasTransition(), 'Abspann must use a slide transition');
|
||||
$effect = $presentation->getTransition()->getEffect();
|
||||
$this->assertNotNull($effect);
|
||||
$this->assertSame('EC52A828-AD85-4602-B70C-1DEE7C904DB6', $effect->getRenderId());
|
||||
$this->assertSame('Dissolve', $effect->getName());
|
||||
$this->assertSame('Dissolves', $effect->getCategory());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-advance (10s), loop-to-first and the Dissolve transition are emitted into
|
||||
* the slideData by addAbspannPresentation and are covered by the parser repo's
|
||||
* in-memory ProFileGeneratorCompletionTest. They are intentionally NOT asserted
|
||||
* here: the parser's generated protobuf descriptor does not (yet) register the
|
||||
* Cue.completion_* and Presentation.transition fields, so serializeToString()
|
||||
* drops them on the disk round-trip that every export performs. Once the parser
|
||||
* stubs are regenerated these become observable on the exported .proplaylist and
|
||||
* this note can be replaced with real assertions.
|
||||
* Auto-advance (10s), loop-to-first and the Dissolve transition survive the disk
|
||||
* round-trip and are asserted in
|
||||
* test_abspann_is_last_item_with_keyvisual_first_then_information_slides. This test
|
||||
* stays focused on the per-cue Abspann macro assignment.
|
||||
*/
|
||||
public function test_abspann_cues_carry_configured_abspann_macro(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProBundleReader;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
*/
|
||||
final class ExportBundleRelativeMediaTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -124,14 +126,16 @@ public function test_probundle_export_referenziert_vordergrund_medien_bundle_rel
|
|||
$bundle = ProBundleReader::read($bundlePath);
|
||||
$slides = $bundle->getSong()->getSlides();
|
||||
|
||||
$mediaSlides = array_filter($slides, fn ($slide) => $slide->hasMedia());
|
||||
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-media slide in the bundle.');
|
||||
// The foreground image is now a content fill.media element, not a media ACTION.
|
||||
$mediaSlides = array_filter($slides, fn ($slide) => $this->fillMediaUrls($slide) !== []);
|
||||
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-image content slide in the bundle.');
|
||||
|
||||
foreach ($mediaSlides as $slide) {
|
||||
$url = $slide->getMediaUrl();
|
||||
$this->assertNotNull($url);
|
||||
// Bundle-relative resources use a bare basename (no absolute path / no slash).
|
||||
$this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).');
|
||||
$this->assertFalse($this->hasImageMediaAction($slide), 'No image media ACTION may remain.');
|
||||
foreach ($this->fillMediaUrls($slide) as $url) {
|
||||
// Bundle-relative resources use a bare basename (no absolute path / no slash).
|
||||
$this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).');
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($bundlePath);
|
||||
|
|
|
|||
221
tests/Feature/ExportProFileProbundleEmbedTest.php
Normal file
221
tests/Feature/ExportProFileProbundleEmbedTest.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ExportProFile;
|
||||
use App\Models\Label;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Services\PlaylistExportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Regression cover for the "empty keyword/prefix show" bug: a .probundle uploaded
|
||||
* as a keyword/prefix/postfix export is stored by Laravel under a MIME-guessed
|
||||
* .zip extension. Branching on the stored path then misses the probundle branch
|
||||
* and embeds the raw ZIP bytes as a broken .pro (ProPresenter shows 0 slides).
|
||||
*
|
||||
* These tests use REAL, slide-bearing .pro bytes (generated by the parser) wrapped
|
||||
* in a real ZIP — dummy string bytes are exactly what hid the original bug.
|
||||
*/
|
||||
final class ExportProFileProbundleEmbedTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
Storage::fake('public');
|
||||
}
|
||||
|
||||
/** Build real, parseable .pro bytes with the given groups/arrangements. */
|
||||
private function realProBytes(string $name, array $groups, array $arrangements): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'fixture-pro-').'.pro';
|
||||
ProFileGenerator::generateAndWrite($tmp, $name, $groups, $arrangements);
|
||||
$bytes = file_get_contents($tmp);
|
||||
@unlink($tmp);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/** Wrap real .pro bytes (+ optional media) into a real .probundle ZIP, return the archive bytes. */
|
||||
private function probundleBytes(string $proBytes, array $mediaFiles = []): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'fixture-bundle-').'.probundle';
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('presentation.pro', $proBytes);
|
||||
foreach ($mediaFiles as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$bytes = file_get_contents($tmp);
|
||||
@unlink($tmp);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
private function createSongWithContent(string $title): Song
|
||||
{
|
||||
$song = Song::create([
|
||||
'title' => $title,
|
||||
'ccli_id' => fake()->unique()->numerify('#####'),
|
||||
'author' => 'Test Author',
|
||||
'copyright_text' => 'Test Publisher',
|
||||
]);
|
||||
|
||||
$label = Label::firstOrCreate(
|
||||
['name' => 'Verse - '.$title],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
|
||||
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
||||
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 cleanupTempDir(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = scandir($dir);
|
||||
if ($items === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.'/'.$item;
|
||||
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
public function test_probundle_mis_stored_as_zip_wird_mit_folien_injiziert(): void
|
||||
{
|
||||
$service = Service::factory()->create(['title' => 'Bundle Service', 'date' => now()]);
|
||||
|
||||
$song = $this->createSongWithContent('Lied');
|
||||
ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Lied',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// Real inner .pro with a group of 2 content slides.
|
||||
$proBytes = $this->realProBytes('intro', [
|
||||
['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => [
|
||||
['text' => 'Folie eins'],
|
||||
['text' => 'Folie zwei'],
|
||||
]],
|
||||
], [['name' => 'normal', 'groupNames' => ['Verse']]]);
|
||||
$bundleBytes = $this->probundleBytes($proBytes, ['hintergrund.jpg' => 'jpg-bytes']);
|
||||
|
||||
// Reproduce the mis-stored state: original name .probundle, stored path .zip.
|
||||
$storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip';
|
||||
Storage::disk('local')->put($storedPath, $bundleBytes);
|
||||
|
||||
ExportProFile::create([
|
||||
'type' => 'prefix',
|
||||
'original_name' => 'intro.probundle',
|
||||
'stored_path' => $storedPath,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries());
|
||||
$this->assertContains('intro', $entryNames, 'Prefix bundle presentation must be injected');
|
||||
|
||||
$embeddedSong = $playlist->getEmbeddedSong('PREFIX_1_intro.pro');
|
||||
$this->assertNotNull($embeddedSong, 'Inner .pro must be embedded, not the raw ZIP');
|
||||
$this->assertCount(2, $this->allParserSlides($embeddedSong), 'Inner .pro must keep its 2 content slides');
|
||||
|
||||
$embeddedFiles = $playlist->getEmbeddedFiles();
|
||||
$this->assertArrayHasKey('hintergrund.jpg', $embeddedFiles, 'Bundle media must be embedded');
|
||||
$this->assertSame('jpg-bytes', $embeddedFiles['hintergrund.jpg']);
|
||||
|
||||
$this->assertSame([], $result['warnings'], 'A slide-bearing bundle must not be skipped');
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_probundle_ohne_folien_wird_uebersprungen_mit_warnung(): void
|
||||
{
|
||||
$service = Service::factory()->create(['title' => 'Leer Service', 'date' => now()]);
|
||||
|
||||
$song = $this->createSongWithContent('Lied');
|
||||
ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Lied',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// Real inner .pro that parses fine but has a group with ZERO slides.
|
||||
$proBytes = $this->realProBytes('leer', [
|
||||
['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => []],
|
||||
], [['name' => 'normal', 'groupNames' => ['Verse']]]);
|
||||
$bundleBytes = $this->probundleBytes($proBytes);
|
||||
|
||||
$storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip';
|
||||
Storage::disk('local')->put($storedPath, $bundleBytes);
|
||||
|
||||
ExportProFile::create([
|
||||
'type' => 'prefix',
|
||||
'original_name' => 'leer.probundle',
|
||||
'stored_path' => $storedPath,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries());
|
||||
$this->assertNotContains('leer', $entryNames, 'A contentless bundle must not be injected');
|
||||
$this->assertNull($playlist->getEmbeddedSong('PREFIX_1_leer.pro'), 'A contentless bundle must not be embedded');
|
||||
|
||||
$this->assertContains(
|
||||
"Export-Datei 'leer.probundle' übersprungen: keine Folien gefunden.",
|
||||
$result['warnings'],
|
||||
'A contentless bundle must be skipped with a German warning',
|
||||
);
|
||||
|
||||
// The valid song is still exported.
|
||||
$this->assertContains('Lied', $entryNames);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,10 +13,12 @@
|
|||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class FullPlaylistExportTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -114,8 +116,12 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
|
|||
$songSlides = $this->allParserSlides($songParser);
|
||||
$this->assertNotEmpty($songSlides);
|
||||
foreach ($songSlides as $slide) {
|
||||
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media');
|
||||
// Service background stays a BACKGROUND media action behind the song text.
|
||||
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background action');
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
$this->assertTrue($this->elementHasTextAt($slide, 0));
|
||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
}
|
||||
|
||||
$embeddedMedia = $playlist->getEmbeddedMediaFiles();
|
||||
|
|
@ -129,7 +135,13 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
|
|||
$sermonSlides = $this->allParserSlides($sermonParser);
|
||||
$this->assertCount(2, $sermonSlides);
|
||||
foreach ($sermonSlides as $slide) {
|
||||
$this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background media');
|
||||
// Uploaded sermon image is a content element with the service background
|
||||
// behind it as a BACKGROUND media action (background is NOT a content element).
|
||||
$this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background action');
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
$this->assertNotEmpty($this->fillMediaUrls($slide));
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
}
|
||||
|
||||
$this->assertSame($slideCountBefore, Slide::count());
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@
|
|||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class KeyVisualFallbackTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -153,7 +155,11 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_
|
|||
|
||||
$slides = $this->allParserSlides($sermonSong);
|
||||
$this->assertCount(1, $slides);
|
||||
// Sermon slide is a foreground content image; no service background configured → no background layer.
|
||||
$this->assertFalse($slides[0]->hasBackgroundMedia());
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slides[0]));
|
||||
$this->assertNotEmpty($this->fillMediaUrls($slides[0]));
|
||||
$this->assertFalse($this->hasImageMediaAction($slides[0]));
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@
|
|||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\PlaylistArchive;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PlaylistSequenceTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -66,12 +68,18 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
|
|||
$introSlides = $this->slidesForEntry($playlist, $entries[$offset]);
|
||||
$this->assertCount(2, $introSlides);
|
||||
|
||||
// Key-visual is a BACKGROUND media action on both intro cues (no content element).
|
||||
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
||||
$this->assertSame([], $this->fillMediaUrls($introSlides[0]));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($introSlides[0]));
|
||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
// Second cue: key-visual as a BACKGROUND media action with the name-tag text element above it.
|
||||
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
|
||||
$this->assertTrue($this->elementHasTextAt($introSlides[1], 0));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($introSlides[1]));
|
||||
// Name and role are split: the name is the main (\fs84) run and the
|
||||
// role is a separate smaller, non-bold (\b0\fs50) subtitle run.
|
||||
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
||||
|
|
@ -289,10 +297,14 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n
|
|||
// The intro .pro carries both cues: key-visual alone + key-visual with name tag.
|
||||
$introSlides = $this->slidesForEntry($playlist, $entries[0]);
|
||||
$this->assertCount(2, $introSlides);
|
||||
// Key-visual is a BACKGROUND media action on both intro cues (no content element).
|
||||
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
||||
$this->assertSame([], $this->fillMediaUrls($introSlides[0]));
|
||||
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
|
||||
$this->assertTrue($this->elementHasTextAt($introSlides[1], 0));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($introSlides[1]));
|
||||
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
||||
$this->assertSame('Erika Predigt', $name);
|
||||
$this->assertSame('Predigt', $subtitle);
|
||||
|
|
|
|||
266
tests/Feature/PlaylistSermonMacroTest.php
Normal file
266
tests/Feature/PlaylistSermonMacroTest.php
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Slide;
|
||||
use App\Services\PlaylistExportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PlaylistSermonMacroTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-based sermon export must attach position macros (first_slide /
|
||||
* last_slide / all_slides) for part_type='sermon' onto the sermon slides.
|
||||
* The parser stores only one macro per slide (the app keeps $macros[0]); the
|
||||
* lowest `order` among the matching assignments wins, so first/last override
|
||||
* all_slides on the first/last slide while the middle slide keeps all_slides.
|
||||
*/
|
||||
public function test_playlist_sermon_slides_carry_position_macros(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
|
||||
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
|
||||
Storage::disk('public')->put('slides/sermon-3.jpg', 'sermon-three');
|
||||
|
||||
$macroFirst = Macro::factory()->create(['name' => 'Predigt Erste']);
|
||||
$macroLast = Macro::factory()->create(['name' => 'Predigt Letzte']);
|
||||
$macroAll = Macro::factory()->create(['name' => 'Predigt Alle']);
|
||||
|
||||
// Lower `order` wins when several assignments match one slide.
|
||||
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroFirst->id, 'position' => 'first_slide', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroLast->id, 'position' => 'last_slide', 'order' => 1]);
|
||||
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroAll->id, 'position' => 'all_slides', 'order' => 2]);
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Predigt Macro Service',
|
||||
'date' => now(),
|
||||
]);
|
||||
|
||||
// A song with real (DB-driven) content guarantees the export always has at
|
||||
// least one content presentation, so it never hits the "Keine Songs mit
|
||||
// Inhalt zum Exportieren gefunden" guard regardless of test ordering or
|
||||
// slide-file resolution. `responsible => []` pins the agenda items so the
|
||||
// factory's random responsible data cannot inject stray name-tag slides.
|
||||
$song = $this->createSongWithContent('Startlied');
|
||||
$serviceSong = ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Startlied',
|
||||
'order' => 1,
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Startlied',
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
'responsible' => [],
|
||||
]);
|
||||
|
||||
$sermonItem = ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Predigt',
|
||||
'service_song_id' => null,
|
||||
'sort_order' => 2,
|
||||
'is_before_event' => false,
|
||||
'responsible' => [],
|
||||
]);
|
||||
$this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0);
|
||||
$this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1);
|
||||
$this->createSermonSlide($service, $sermonItem, 'sermon-3.jpg', 2);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$sermon = $playlist->getEmbeddedSong('Predigt.pro');
|
||||
$this->assertNotNull($sermon, 'Embedded sermon .pro missing');
|
||||
|
||||
$slides = $this->allParserSlides($sermon);
|
||||
$this->assertCount(3, $slides);
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$this->assertTrue($slide->hasMacro(), 'Every sermon slide must carry a macro (all_slides guarantees at least one)');
|
||||
}
|
||||
|
||||
// First slide: first_slide wins over all_slides (lower order).
|
||||
$this->assertSame('Predigt Erste', $slides[0]->getMacroName());
|
||||
$this->assertSame($macroFirst->uuid, $slides[0]->getMacroUuid());
|
||||
|
||||
// Middle slide: only all_slides matches.
|
||||
$this->assertSame('Predigt Alle', $slides[1]->getMacroName());
|
||||
|
||||
// Last slide: last_slide wins over all_slides (lower order).
|
||||
$this->assertSame('Predigt Letzte', $slides[2]->getMacroName());
|
||||
$this->assertSame($macroLast->uuid, $slides[2]->getMacroUuid());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy (no-agenda) export path emits Information, Moderation and Sermon
|
||||
* presentations with their own explicit part_type. Each must attach the
|
||||
* all_slides macro registered for that part_type onto every slide.
|
||||
*/
|
||||
public function test_legacy_information_moderation_sermon_slides_carry_macros(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/info-1.jpg', 'info-one');
|
||||
Storage::disk('public')->put('slides/info-2.jpg', 'info-two');
|
||||
Storage::disk('public')->put('slides/mod-1.jpg', 'mod-one');
|
||||
Storage::disk('public')->put('slides/mod-2.jpg', 'mod-two');
|
||||
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
|
||||
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
|
||||
|
||||
$infoMacro = Macro::factory()->create(['name' => 'Info Macro']);
|
||||
$modMacro = Macro::factory()->create(['name' => 'Moderation Macro']);
|
||||
$sermonMacro = Macro::factory()->create(['name' => 'Predigt Macro']);
|
||||
|
||||
MacroAssignment::create(['part_type' => 'information', 'macro_id' => $infoMacro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'moderation', 'macro_id' => $modMacro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $sermonMacro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
// No agenda items => the export uses the legacy block-ordered path.
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Legacy Macro Service',
|
||||
'date' => now(),
|
||||
]);
|
||||
|
||||
$this->createInfoSlide('info-1.jpg', 0);
|
||||
$this->createInfoSlide('info-2.jpg', 1);
|
||||
$this->createServiceSlide($service, 'moderation', 'mod-1.jpg', 0);
|
||||
$this->createServiceSlide($service, 'moderation', 'mod-2.jpg', 1);
|
||||
$this->createServiceSlide($service, 'sermon', 'sermon-1.jpg', 0);
|
||||
$this->createServiceSlide($service, 'sermon', 'sermon-2.jpg', 1);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Informationen.pro'), 'Info Macro');
|
||||
$this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Moderation.pro'), 'Moderation Macro');
|
||||
$this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Predigt.pro'), 'Predigt Macro');
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
private function assertAllSlidesHaveMacro(?\ProPresenter\Parser\Song $song, string $macroName): void
|
||||
{
|
||||
$this->assertNotNull($song, "Embedded .pro for macro '{$macroName}' missing");
|
||||
|
||||
$slides = $this->allParserSlides($song);
|
||||
$this->assertCount(2, $slides);
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$this->assertTrue($slide->hasMacro(), "Slide for '{$macroName}' must carry a macro");
|
||||
$this->assertSame($macroName, $slide->getMacroName());
|
||||
}
|
||||
}
|
||||
|
||||
private function createSermonSlide(Service $service, ServiceAgendaItem $agendaItem, string $filename, int $sortOrder): Slide
|
||||
{
|
||||
return Slide::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'service_agenda_item_id' => $agendaItem->id,
|
||||
'type' => 'sermon',
|
||||
'original_filename' => $filename,
|
||||
'stored_filename' => 'slides/'.$filename,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createInfoSlide(string $filename, int $sortOrder): Slide
|
||||
{
|
||||
return Slide::factory()->create([
|
||||
'service_id' => null,
|
||||
'type' => 'information',
|
||||
'original_filename' => $filename,
|
||||
'stored_filename' => 'slides/'.$filename,
|
||||
'sort_order' => $sortOrder,
|
||||
'expire_date' => null,
|
||||
'uploaded_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createServiceSlide(Service $service, string $type, string $filename, int $sortOrder): Slide
|
||||
{
|
||||
return Slide::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'type' => $type,
|
||||
'original_filename' => $filename,
|
||||
'stored_filename' => 'slides/'.$filename,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createSongWithContent(string $title): \App\Models\Song
|
||||
{
|
||||
$song = \App\Models\Song::create([
|
||||
'title' => $title,
|
||||
'ccli_id' => fake()->unique()->numerify('#####'),
|
||||
'author' => 'Test Author',
|
||||
'copyright_text' => 'Test Publisher',
|
||||
]);
|
||||
|
||||
$label = \App\Models\Label::firstOrCreate(
|
||||
['name' => 'Verse - '.$title],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
|
||||
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
||||
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 cleanupTempDir(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = scandir($dir);
|
||||
if ($items === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.'/'.$item;
|
||||
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,10 +18,12 @@
|
|||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProBundleReader;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ProFileExportTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createSongWithContent(): Song
|
||||
|
|
@ -292,9 +294,13 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
|
|||
|
||||
$this->assertNotEmpty($slides);
|
||||
foreach ($slides as $slide) {
|
||||
// Service background stays a BACKGROUND media action behind the song text.
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
|
||||
// Text is a content element; no image content element, no foreground action.
|
||||
$this->assertTrue($this->elementHasTextAt($slide, 0));
|
||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,7 +367,10 @@ public function test_export_ohne_background_enthaelt_keine_background_actions():
|
|||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||
|
||||
foreach ($this->allParserSlides($parserSong) as $slide) {
|
||||
// No background configured → no background fill.media element and no media ACTION.
|
||||
$this->assertFalse($slide->hasBackgroundMedia());
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
||||
$this->assertFalse($this->hasImageMediaAction($slide));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -398,9 +407,18 @@ public function test_sermon_export_ueberspringt_background_bei_full_cover_folien
|
|||
$slides = $this->allParserSlides($bundle->getSong());
|
||||
|
||||
$this->assertCount(2, $slides);
|
||||
// contain slide: the uploaded image is a content element with the service
|
||||
// background behind it as a BACKGROUND media action.
|
||||
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
|
||||
$this->assertSame('contain.jpg', $this->fillMediaUrlAt($slides[0], 0));
|
||||
$this->assertTrue($this->isFullFrameAt($slides[0], 0));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slides[0]));
|
||||
// cover slide: uploaded content element only, no background layer.
|
||||
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slides[1]));
|
||||
$this->assertSame('cover.jpg', $this->fillMediaUrlAt($slides[1], 0));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slides[1]));
|
||||
$this->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name');
|
||||
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
|
||||
|
||||
|
|
@ -437,7 +455,9 @@ public function test_information_und_moderation_exports_erhalten_keinen_backgrou
|
|||
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
|
||||
|
||||
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
|
||||
// Info/moderation slides carry only the foreground image element, never a background layer.
|
||||
$this->assertFalse($slide->hasBackgroundMedia());
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
||||
}
|
||||
|
||||
@unlink($bundlePath);
|
||||
|
|
@ -504,12 +524,21 @@ public function test_playlist_export_setzt_background_auf_sermon_folien_und_nich
|
|||
|
||||
foreach ($this->allParserSlides($informationSong) as $slide) {
|
||||
$this->assertFalse($slide->hasBackgroundMedia());
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
||||
}
|
||||
|
||||
$sermonSlides = $this->allParserSlides($sermonSong);
|
||||
$this->assertCount(2, $sermonSlides);
|
||||
// contain slide: uploaded content element with the service background behind it
|
||||
// as a BACKGROUND media action (background is NOT a content element).
|
||||
$this->assertTrue($sermonSlides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $sermonSlides[0]->getBackgroundMediaUrl());
|
||||
$this->assertNotEmpty($this->fillMediaUrls($sermonSlides[0]));
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[0]));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($sermonSlides[0]));
|
||||
// cover slide: uploaded content element only, no background layer.
|
||||
$this->assertFalse($sermonSlides[1]->hasBackgroundMedia());
|
||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[1]));
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
|
|
|||
220
tests/Feature/SlideMediaElementTest.php
Normal file
220
tests/Feature/SlideMediaElementTest.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Service;
|
||||
use App\Models\Song;
|
||||
use App\Services\ProExportService;
|
||||
use App\Services\SlideMediaElementBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
use ProPresenter\Parser\ProFileReader;
|
||||
use ProPresenter\Parser\Slide as ParserSlide;
|
||||
use Tests\Support\InspectsSlideFillMedia;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Verifies the COMBINED image rule enforced by SlideMediaElementBuilder:
|
||||
*
|
||||
* 1. Song slide → text element + BACKGROUND media action, NO image element.
|
||||
* 2. Uploaded content slide → full-frame fill.media content element, NO foreground
|
||||
* action (a service background, if set, stays a
|
||||
* BACKGROUND media action behind the element).
|
||||
* 3. Key-visual slide → BACKGROUND media action only, NO content element.
|
||||
* 4. Name-tag slide → text element + BACKGROUND media action + macro.
|
||||
*/
|
||||
final class SlideMediaElementTest extends TestCase
|
||||
{
|
||||
use InspectsSlideFillMedia;
|
||||
use RefreshDatabase;
|
||||
|
||||
private function bgData(string $name = 'BACKGROUND.jpg'): array
|
||||
{
|
||||
return ['path' => $name, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, 'bundleRelative' => true];
|
||||
}
|
||||
|
||||
/** Generate a one-slide song and run the production builder over it. */
|
||||
private function build(array $slideData, string $name = 'Test'): ParserSlide
|
||||
{
|
||||
$groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]];
|
||||
$arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]];
|
||||
|
||||
$song = ProFileGenerator::generate($name, $groups, $arrangements);
|
||||
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
|
||||
|
||||
return $song->getSlides()[0];
|
||||
}
|
||||
|
||||
public function test_song_slide_keeps_background_action_and_text_without_image_element(): void
|
||||
{
|
||||
$groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [[
|
||||
'text' => 'Textzeile',
|
||||
'background' => $this->bgData(),
|
||||
]]]];
|
||||
$arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]];
|
||||
|
||||
$song = ProFileGenerator::generate('Song', $groups, $arrangements);
|
||||
|
||||
// Sanity: the generator emits a BACKGROUND media action before conversion.
|
||||
$this->assertTrue($song->getSlides()[0]->hasBackgroundMedia());
|
||||
|
||||
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
|
||||
$slide = $song->getSlides()[0];
|
||||
|
||||
// Background stays a BACKGROUND media action (NOT converted to an element).
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
|
||||
// Text element preserved; no content fill.media element exists.
|
||||
$this->assertTrue($this->elementHasTextAt($slide, 0));
|
||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
||||
|
||||
// No foreground image action.
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
$this->assertFalse($slide->hasMedia());
|
||||
}
|
||||
|
||||
public function test_uploaded_content_slide_becomes_full_frame_element_without_foreground_action(): void
|
||||
{
|
||||
// Production uploaded-slide data: media + format + label + bundleRelative (no text).
|
||||
$slide = $this->build([
|
||||
'media' => 'UPLOAD.jpg',
|
||||
'format' => 'JPG',
|
||||
'label' => 'upload.jpg',
|
||||
'bundleRelative' => true,
|
||||
]);
|
||||
|
||||
// A single full-frame content fill.media element, no text.
|
||||
$this->assertCount(1, $this->baseSlideElements($slide));
|
||||
$this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0));
|
||||
$this->assertTrue($this->isFullFrameAt($slide, 0));
|
||||
$this->assertFalse($this->elementHasTextAt($slide, 0));
|
||||
|
||||
// No foreground media action remains; no background configured at all.
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
$this->assertFalse($slide->hasBackgroundMedia());
|
||||
$this->assertFalse($this->hasImageMediaAction($slide));
|
||||
}
|
||||
|
||||
public function test_uploaded_content_slide_with_service_background_keeps_background_action_behind_element(): void
|
||||
{
|
||||
$slide = $this->build([
|
||||
'media' => 'UPLOAD.jpg',
|
||||
'format' => 'JPG',
|
||||
'label' => 'upload.jpg',
|
||||
'bundleRelative' => true,
|
||||
'background' => $this->bgData(),
|
||||
]);
|
||||
|
||||
// Uploaded image is a full-frame content element on top.
|
||||
$this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0));
|
||||
$this->assertTrue($this->isFullFrameAt($slide, 0));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
|
||||
// Service background stays a BACKGROUND media action behind the element.
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
}
|
||||
|
||||
public function test_key_visual_slide_is_background_action_only_without_content_element(): void
|
||||
{
|
||||
// Production key-visual slide data: imageOnly + background.
|
||||
$slide = $this->build([
|
||||
'imageOnly' => true,
|
||||
'background' => $this->bgData('KEY_VISUAL.jpg'),
|
||||
]);
|
||||
|
||||
// Key-visual is a BACKGROUND media action, not a content element.
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
|
||||
|
||||
// No content fill.media element and no foreground action.
|
||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
}
|
||||
|
||||
public function test_name_tag_slide_keeps_text_background_action_and_macro(): void
|
||||
{
|
||||
$slide = $this->build([
|
||||
'text' => 'Erika Predigt',
|
||||
'subtitle' => 'Predigt',
|
||||
'background' => $this->bgData('KEY_VISUAL.jpg'),
|
||||
'macro' => [
|
||||
'name' => 'Namenseinblender',
|
||||
'uuid' => '11111111-1111-4111-8111-111111111111',
|
||||
'collectionName' => '--MAIN--',
|
||||
'collectionUuid' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
// Text element preserved (name-tag text sits above the background).
|
||||
$this->assertTrue($this->elementHasTextAt($slide, 0));
|
||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
||||
|
||||
// Key-visual behind the name tag stays a BACKGROUND media action.
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
|
||||
|
||||
// Macro action preserved.
|
||||
$this->assertTrue($slide->hasMacro());
|
||||
$this->assertSame('Namenseinblender', $slide->getMacroName());
|
||||
|
||||
// No image content element, no foreground action.
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
}
|
||||
|
||||
public function test_exported_song_pro_roundtrip_keeps_background_action_and_text(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
||||
|
||||
$service = Service::factory()->create(['background_filename' => 'slides/background.jpg']);
|
||||
$song = $this->createSongWithContent();
|
||||
|
||||
$path = app(ProExportService::class)->generateProFile($song, $service);
|
||||
$parserSong = ProFileReader::read($path);
|
||||
@unlink($path);
|
||||
|
||||
$slides = [];
|
||||
foreach ($parserSong->getGroups() as $group) {
|
||||
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotEmpty($slides);
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
// Background survives the .pro write+read as a BACKGROUND media action.
|
||||
$this->assertTrue($slide->hasBackgroundMedia());
|
||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||
// Text preserved; no content image element; no foreground action.
|
||||
$this->assertTrue($this->elementHasTextAt($slide, 0));
|
||||
$this->assertNotSame('', $slide->getPlainText());
|
||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
||||
}
|
||||
}
|
||||
|
||||
private function createSongWithContent(): Song
|
||||
{
|
||||
$song = Song::create([
|
||||
'title' => 'Fill Media Song',
|
||||
'ccli_id' => '99887',
|
||||
'author' => 'Test Author',
|
||||
'copyright_text' => 'Test Publisher',
|
||||
]);
|
||||
|
||||
$verse = Label::firstOrCreate(['name' => 'Verse 1 - Fill Media Song'], ['color' => '#2196F3']);
|
||||
$section = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
|
||||
$section->slides()->create(['order' => 1, 'text_content' => 'Zweite Zeile']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
}
|
||||
174
tests/Support/InspectsSlideFillMedia.php
Normal file
174
tests/Support/InspectsSlideFillMedia.php
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use ProPresenter\Parser\Slide;
|
||||
use Rv\Data\Action\ActionType;
|
||||
use Rv\Data\Action\LayerType;
|
||||
|
||||
/**
|
||||
* Test helpers for the COMBINED image rule:
|
||||
*
|
||||
* - BACKGROUND service image / key-visual → media ACTION on the BACKGROUND
|
||||
* layer. Assert via the parser API: $slide->hasBackgroundMedia() and
|
||||
* $slide->getBackgroundMediaUrl().
|
||||
* - UPLOADED content slide image → full-frame fill.media CONTENT element.
|
||||
* Assert via fillMediaUrlAt()/fillMediaUrls()/isFullFrameAt() and the
|
||||
* foreground-action absence via hasForegroundImageMediaAction() == false.
|
||||
* - Text (songtext / name-tag) → text CONTENT element (elementHasTextAt()).
|
||||
*
|
||||
* Content elements are full-frame Graphics\Element fills whose z-order is the
|
||||
* base-slide element array order (index 0 = bottom layer): text first, the
|
||||
* uploaded foreground image appended on top. The background image is NOT an
|
||||
* element; it renders beneath the stack from its BACKGROUND media action.
|
||||
*/
|
||||
trait InspectsSlideFillMedia
|
||||
{
|
||||
/**
|
||||
* Ordered base-slide content elements (z-order; index 0 = bottom layer).
|
||||
*
|
||||
* @return array<int, \Rv\Data\Slide\Element>
|
||||
*/
|
||||
protected function baseSlideElements(Slide $slide): array
|
||||
{
|
||||
foreach ($slide->getCue()->getActions() as $action) {
|
||||
if ($action->getType() !== ActionType::ACTION_TYPE_PRESENTATION_SLIDE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseSlide = $action->getSlide()?->getPresentation()?->getBaseSlide();
|
||||
if ($baseSlide === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$elements = [];
|
||||
foreach ($baseSlide->getElements() as $element) {
|
||||
$elements[] = $element;
|
||||
}
|
||||
|
||||
return $elements;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Bundle-relative fill-media URL of the element at $index, or null when it has no media fill. */
|
||||
protected function fillMediaUrlAt(Slide $slide, int $index): ?string
|
||||
{
|
||||
$elements = $this->baseSlideElements($slide);
|
||||
$graphics = ($elements[$index] ?? null)?->getElement();
|
||||
$fill = $graphics?->getFill();
|
||||
|
||||
if ($fill === null || ! $fill->hasMedia()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fill->getMedia()?->getUrl()?->getAbsoluteString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds [x, y, width, height] of the element at $index, or null.
|
||||
*
|
||||
* @return array{0: float, 1: float, 2: float, 3: float}|null
|
||||
*/
|
||||
protected function fillBoundsAt(Slide $slide, int $index): ?array
|
||||
{
|
||||
$elements = $this->baseSlideElements($slide);
|
||||
$bounds = ($elements[$index] ?? null)?->getElement()?->getBounds();
|
||||
if ($bounds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
(float) ($bounds->getOrigin()?->getX() ?? 0.0),
|
||||
(float) ($bounds->getOrigin()?->getY() ?? 0.0),
|
||||
(float) ($bounds->getSize()?->getWidth() ?? 0.0),
|
||||
(float) ($bounds->getSize()?->getHeight() ?? 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
/** True when the element at $index is a full-frame (0,0 → 1920×1080) fill. */
|
||||
protected function isFullFrameAt(Slide $slide, int $index): bool
|
||||
{
|
||||
return $this->fillBoundsAt($slide, $index) === [0.0, 0.0, 1920.0, 1080.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* All fill-media URLs on the slide, in z-order (bottom → top).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function fillMediaUrls(Slide $slide): array
|
||||
{
|
||||
$urls = [];
|
||||
foreach ($this->baseSlideElements($slide) as $element) {
|
||||
$fill = $element->getElement()?->getFill();
|
||||
if ($fill !== null && $fill->hasMedia()) {
|
||||
$urls[] = $fill->getMedia()?->getUrl()?->getAbsoluteString() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/** Whether any base-slide element carries a fill-media image with the given URL. */
|
||||
protected function hasFillMediaUrl(Slide $slide, string $url): bool
|
||||
{
|
||||
return in_array($url, $this->fillMediaUrls($slide), true);
|
||||
}
|
||||
|
||||
/** True while the element at $index carries text (used to assert text sits above the background). */
|
||||
protected function elementHasTextAt(Slide $slide, int $index): bool
|
||||
{
|
||||
$graphics = ($this->baseSlideElements($slide)[$index] ?? null)?->getElement();
|
||||
|
||||
return $graphics !== null && $graphics->hasText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cue carries ANY image MEDIA action (either layer). Useful only
|
||||
* when no service background is configured; when a background IS present it
|
||||
* survives as a BACKGROUND action, so prefer hasForegroundImageMediaAction().
|
||||
*/
|
||||
protected function hasImageMediaAction(Slide $slide): bool
|
||||
{
|
||||
foreach ($slide->getCue()->getActions() as $action) {
|
||||
if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mediaElement = $action->getMedia()?->getElement();
|
||||
if ($mediaElement !== null && $mediaElement->hasImage()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cue still carries a FOREGROUND-layer image MEDIA action. Must be
|
||||
* false after conversion — uploaded images become content elements. A
|
||||
* BACKGROUND-layer action (service background / key-visual) is ignored here.
|
||||
*/
|
||||
protected function hasForegroundImageMediaAction(Slide $slide): bool
|
||||
{
|
||||
foreach ($slide->getCue()->getActions() as $action) {
|
||||
if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mediaType = $action->getMedia();
|
||||
$mediaElement = $mediaType?->getElement();
|
||||
if (
|
||||
$mediaElement !== null
|
||||
&& $mediaElement->hasImage()
|
||||
&& $mediaType->getLayerType() !== LayerType::LAYER_TYPE_BACKGROUND
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,13 +16,13 @@ git fetch --all --prune
|
|||
# und database/ gitignored sind und nicht angefasst werden.
|
||||
git reset --hard origin/master
|
||||
|
||||
# Die propresenter/parser-Version wird ausschliesslich ueber die eingecheckte
|
||||
# Die bussnet/propresenter7-php-lib-Version wird ausschliesslich ueber die eingecheckte
|
||||
# composer.lock gepinnt (Standard-Composer-Praxis). Der Dockerfile-Schritt
|
||||
# "composer install" deployt exakt diesen Lock-Stand; "COPY composer.json
|
||||
# composer.lock" bricht die Composer-Layer automatisch, sobald sich die Lock
|
||||
# aendert. WICHTIG: composer.lock committen, wenn der Parser fortschreitet.
|
||||
# (Kein "composer update" hier: master des Parsers wurde umbenannt zu
|
||||
# bussnet/propresenter7-php-api, ein Re-Resolve von dev-master schlaegt fehl.)
|
||||
# bussnet/propresenter7-php-lib, ein Re-Resolve von dev-master schlaegt fehl.)
|
||||
|
||||
echo "[deploy] Baue Image (inkl. aktualisierter Basis-Images) ..."
|
||||
$DC build --pull
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Toggle propresenter/parser between remote VCS and local path repository.
|
||||
# Toggle bussnet/propresenter7-php-lib between remote VCS and local path repository.
|
||||
#
|
||||
# Usage:
|
||||
# ./use_local_pp_lib.sh <path> — Switch to local checkout (symlinked)
|
||||
|
|
@ -39,13 +39,13 @@ switch_to_remote() {
|
|||
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
'
|
||||
|
||||
if [[ -L "vendor/propresenter/parser" ]] || [[ -d "vendor/propresenter/parser" ]]; then
|
||||
rm -rf vendor/propresenter/parser
|
||||
if [[ -L "vendor/bussnet/propresenter7-php-lib" ]] || [[ -d "vendor/bussnet/propresenter7-php-lib" ]]; then
|
||||
rm -rf vendor/bussnet/propresenter7-php-lib
|
||||
fi
|
||||
|
||||
composer update propresenter/parser
|
||||
composer update bussnet/propresenter7-php-lib
|
||||
echo ""
|
||||
echo "propresenter/parser wird jetzt vom Remote-Repository geladen."
|
||||
echo "bussnet/propresenter7-php-lib wird jetzt vom Remote-Repository geladen."
|
||||
}
|
||||
|
||||
switch_to_local() {
|
||||
|
|
@ -63,8 +63,8 @@ switch_to_local() {
|
|||
|
||||
local PKG_NAME
|
||||
PKG_NAME=$(php -r "echo json_decode(file_get_contents('$LOCAL_PATH/composer.json'), true)['name'] ?? '';")
|
||||
if [[ "$PKG_NAME" != "propresenter/parser" ]]; then
|
||||
echo "Error: Package in $LOCAL_PATH ist '$PKG_NAME', erwartet 'propresenter/parser'." >&2
|
||||
if [[ "$PKG_NAME" != "bussnet/propresenter7-php-lib" ]]; then
|
||||
echo "Error: Package in $LOCAL_PATH ist '$PKG_NAME', erwartet 'bussnet/propresenter7-php-lib'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -88,13 +88,13 @@ switch_to_local() {
|
|||
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
' -- "$REL_PATH"
|
||||
|
||||
if [[ -L "vendor/propresenter/parser" ]] || [[ -d "vendor/propresenter/parser" ]]; then
|
||||
rm -rf vendor/propresenter/parser
|
||||
if [[ -L "vendor/bussnet/propresenter7-php-lib" ]] || [[ -d "vendor/bussnet/propresenter7-php-lib" ]]; then
|
||||
rm -rf vendor/bussnet/propresenter7-php-lib
|
||||
fi
|
||||
|
||||
composer update propresenter/parser
|
||||
composer update bussnet/propresenter7-php-lib
|
||||
echo ""
|
||||
echo "propresenter/parser ist jetzt mit $REL_PATH verlinkt (Symlink)."
|
||||
echo "bussnet/propresenter7-php-lib ist jetzt mit $REL_PATH verlinkt (Symlink)."
|
||||
echo "Aenderungen in $REL_PATH sind sofort verfuegbar."
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue