Compare commits
No commits in common. "94170857b67118abe66ce6e2749e4571089ee3f5" and "2a68d098a2361a4471f88558e5f43da1524892ee" have entirely different histories.
94170857b6
...
2a68d098a2
|
|
@ -1,3 +1,8 @@
|
||||||
22
|
ls tests/fixtures/ccli/*.txt | wc -l
|
||||||
4
|
22
|
||||||
2
|
|
||||||
|
grep -rl "Strophe\|Refrain" tests/fixtures/ccli/ | wc -l
|
||||||
|
4
|
||||||
|
|
||||||
|
grep -rl "(Repeat)" tests/fixtures/ccli/ | wc -l
|
||||||
|
2
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
ddev exec php artisan test --filter=CcliFixtureSanityTest
|
||||||
|
|
||||||
PASS Tests\Feature\CcliFixtureSanityTest
|
PASS Tests\Feature\CcliFixtureSanityTest
|
||||||
✓ ccli fixture corpus has at least 20 txt files 0.23s
|
✓ ccli fixture corpus has at least 20 txt files
|
||||||
✓ each ccli fixture is valid utf8 with section labels and title
|
✓ each ccli fixture is valid utf8 with section labels and title
|
||||||
✓ ccli fixture corpus covers german labels
|
✓ ccli fixture corpus covers german labels
|
||||||
✓ ccli fixture corpus covers repeat markers
|
✓ ccli fixture corpus covers repeat markers
|
||||||
|
|
||||||
Tests: 4 passed (160 assertions)
|
|
||||||
Duration: 0.35s
|
|
||||||
|
|
||||||
|
Tests: 4 passed (160 assertions)
|
||||||
|
|
|
||||||
|
|
@ -33,53 +33,3 @@ ### T7: Global Label Slide Replacement Caveat
|
||||||
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
|
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
|
||||||
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
|
- `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.
|
- 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,25 +108,3 @@ ### 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.
|
- `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()`).
|
- 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.
|
- `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.
|
- 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
|
- LoggedIn User should be visible in the Top Bar
|
||||||
- every action should be immediately persistent, no separate "save" button required, unless explicitly described.
|
- 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 (`bussnet/propresenter7-php-lib`) linked via a composer vcs repository
|
- ProPresenter `.pro` file parser/generator is implemented as a separate composer package (`propresenter/parser`) linked via path repository
|
||||||
|
|
||||||
## The Plan
|
## The Plan
|
||||||
|
|
||||||
|
|
@ -239,14 +239,14 @@ ### Routes
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
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`):
|
Two git repositories, both local (no remote):
|
||||||
|
|
||||||
| Repo | Path | Branch | Purpose |
|
| Repo | Path | Branch | Purpose |
|
||||||
|------|------|--------|---------|
|
|------|------|--------|---------|
|
||||||
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `master` | Laravel app (main codebase) |
|
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
|
||||||
| **propresenter** | `/Users/thorsten/AI/propresenter` | `master` | ProPresenter .pro/.proplaylist parser (composer vcs dependency) |
|
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
|
||||||
|
|
||||||
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`).
|
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
|
||||||
|
|
||||||
## Build, Test, Lint Commands
|
## Build, Test, Lint Commands
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ExportProFileController extends Controller
|
class ExportProFileController extends Controller
|
||||||
|
|
@ -27,13 +26,7 @@ public function store(Request $request): JsonResponse
|
||||||
$created = [];
|
$created = [];
|
||||||
|
|
||||||
foreach ($request->file('files') as $file) {
|
foreach ($request->file('files') as $file) {
|
||||||
// Store under a random basename but keep the REAL client extension.
|
$storedPath = $file->store('export-pro-files/'.$type, 'local');
|
||||||
// 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;
|
$maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0;
|
||||||
|
|
||||||
$record = ExportProFile::create([
|
$record = ExportProFile::create([
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProFileGenerator;
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
use ProPresenter\Parser\ProFileReader;
|
|
||||||
use ProPresenter\Parser\ProFileWriter;
|
|
||||||
use ProPresenter\Parser\ProPlaylistGenerator;
|
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||||
use ProPresenter\Parser\Zip64Fixer;
|
use ProPresenter\Parser\Zip64Fixer;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
|
|
@ -97,7 +95,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
'information',
|
'information',
|
||||||
'information',
|
|
||||||
);
|
);
|
||||||
$announcementInserted = true;
|
$announcementInserted = true;
|
||||||
}
|
}
|
||||||
|
|
@ -140,8 +137,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$skippedUnmatched++;
|
$skippedUnmatched++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$partType = $this->backgroundPartTypeForAgendaItem($item);
|
$isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon';
|
||||||
$isSermon = $partType === 'sermon';
|
|
||||||
|
|
||||||
if ($isSermon) {
|
if ($isSermon) {
|
||||||
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
|
@ -157,8 +153,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
$partType,
|
$this->backgroundPartTypeForAgendaItem($item),
|
||||||
$partType,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (count($playlistItems) > $countBefore) {
|
if (count($playlistItems) > $countBefore) {
|
||||||
|
|
@ -176,8 +171,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
$partType,
|
$this->backgroundPartTypeForAgendaItem($item),
|
||||||
$partType,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (count($playlistItems) > $countBefore) {
|
if (count($playlistItems) > $countBefore) {
|
||||||
|
|
@ -209,7 +203,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) {
|
if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) {
|
||||||
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles, $warnings);
|
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles);
|
||||||
if ($keywordItem !== null) {
|
if ($keywordItem !== null) {
|
||||||
$playlistItems[] = $keywordItem;
|
$playlistItems[] = $keywordItem;
|
||||||
}
|
}
|
||||||
|
|
@ -229,7 +223,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$prependFiles,
|
$prependFiles,
|
||||||
$service,
|
$service,
|
||||||
'information',
|
'information',
|
||||||
'information',
|
|
||||||
);
|
);
|
||||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
||||||
|
|
@ -240,7 +233,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);
|
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||||
|
|
||||||
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
|
||||||
|
|
@ -367,7 +360,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
||||||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);
|
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||||
|
|
||||||
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
|
||||||
|
|
@ -405,7 +398,6 @@ private function addSlidesFromCollection(
|
||||||
array &$embeddedFiles,
|
array &$embeddedFiles,
|
||||||
?Service $service = null,
|
?Service $service = null,
|
||||||
?string $backgroundPartType = null,
|
?string $backgroundPartType = null,
|
||||||
?string $macroPartType = null,
|
|
||||||
): void {
|
): void {
|
||||||
$slideDataList = [];
|
$slideDataList = [];
|
||||||
$imageFiles = [];
|
$imageFiles = [];
|
||||||
|
|
@ -448,23 +440,6 @@ private function addSlidesFromCollection(
|
||||||
return;
|
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 = [
|
$groups = [
|
||||||
[
|
[
|
||||||
'name' => $label,
|
'name' => $label,
|
||||||
|
|
@ -541,7 +516,6 @@ private function addSlidePresentation(
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
$type,
|
$type,
|
||||||
$type,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -606,30 +580,28 @@ private function addHeadlineItem(
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, array &$warnings): void
|
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
|
||||||
{
|
{
|
||||||
$prefixItems = $this->buildExportProItems(
|
$prefixItems = $this->buildExportProItems(
|
||||||
ExportProFile::prefix()->orderBy('order')->get(),
|
ExportProFile::prefix()->orderBy('order')->get(),
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$warnings,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$postfixItems = $this->buildExportProItems(
|
$postfixItems = $this->buildExportProItems(
|
||||||
ExportProFile::postfix()->orderBy('order')->get(),
|
ExportProFile::postfix()->orderBy('order')->get(),
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$warnings,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
|
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
|
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
|
||||||
private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded, array &$warnings): array
|
private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded): array
|
||||||
{
|
{
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
$item = $this->embedExportProFile($file, $embedded, $warnings);
|
$item = $this->embedExportProFile($file, $embedded);
|
||||||
if ($item !== null) {
|
if ($item !== null) {
|
||||||
$items[] = $item;
|
$items[] = $item;
|
||||||
}
|
}
|
||||||
|
|
@ -641,64 +613,34 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, arra
|
||||||
/**
|
/**
|
||||||
* Read a single export .pro/.probundle file from the 'local' disk, embed its
|
* Read a single export .pro/.probundle file from the 'local' disk, embed its
|
||||||
* bytes (and any .probundle media) into $embeddedFiles, and return the matching
|
* bytes (and any .probundle media) into $embeddedFiles, and return the matching
|
||||||
* playlist presentation item. Returns null (and records a German warning) when
|
* playlist presentation item. Returns null when the file is missing or a
|
||||||
* the file is missing, a .probundle has no inner .pro entry, or the embedded
|
* .probundle has no inner .pro entry.
|
||||||
* .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
|
* The embedded .pro name uses the uppercased type as marker
|
||||||
* (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
|
* (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
|
||||||
*
|
*
|
||||||
* @return array{type: string, name: string, path: string}|null
|
* @return array{type: string, name: string, path: string}|null
|
||||||
*/
|
*/
|
||||||
private function embedExportProFile(ExportProFile $file, array &$embeddedFiles, array &$warnings): ?array
|
private function embedExportProFile(ExportProFile $file, array &$embeddedFiles): ?array
|
||||||
{
|
{
|
||||||
if (! Storage::disk('local')->exists($file->stored_path)) {
|
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION));
|
||||||
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||||
$embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
|
$embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
|
||||||
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
|
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
|
||||||
|
|
||||||
// Treat both .probundle and .zip as bundles: a .probundle is a ZIP, and
|
if ($ext === 'probundle') {
|
||||||
// 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);
|
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
|
||||||
|
|
||||||
$extracted = $this->extractProBundle($storedAbsPath);
|
$extracted = $this->extractProBundle($storedAbsPath);
|
||||||
if ($extracted === null) {
|
if ($extracted === null) {
|
||||||
$warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[$proBytes, $mediaFiles] = $extracted;
|
[$proBytes, $mediaFiles] = $extracted;
|
||||||
} else {
|
|
||||||
$proBytes = Storage::disk('local')->get($file->stored_path);
|
|
||||||
if ($proBytes === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
foreach ($mediaFiles as $entryName => $entryBytes) {
|
||||||
$mediaName = basename((string) $entryName);
|
$mediaName = basename((string) $entryName);
|
||||||
|
|
@ -708,6 +650,14 @@ private function embedExportProFile(ExportProFile $file, array &$embeddedFiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
$embeddedFiles[$embeddedProName] = $proBytes;
|
$embeddedFiles[$embeddedProName] = $proBytes;
|
||||||
|
} else {
|
||||||
|
$bytes = Storage::disk('local')->get($file->stored_path);
|
||||||
|
if ($bytes === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$embeddedFiles[$embeddedProName] = $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => 'presentation',
|
'type' => 'presentation',
|
||||||
|
|
@ -716,42 +666,6 @@ 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.
|
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
|
||||||
*
|
*
|
||||||
|
|
@ -838,12 +752,7 @@ private function extractProBundle(string $absPath): ?array
|
||||||
|
|
||||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||||
{
|
{
|
||||||
// Split generate → inject → write: convert FOREGROUND (uploaded) image media
|
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements, [], $options);
|
||||||
// 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
|
private function buildModeratorSlideData(Service $service): ?array
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ class ProBundleExportService
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MacroResolutionService $macroResolutionService,
|
private readonly MacroResolutionService $macroResolutionService,
|
||||||
private readonly ServiceImageResolver $imageResolver,
|
private readonly ServiceImageResolver $imageResolver,
|
||||||
private readonly SlideMediaElementBuilder $slideMediaElementBuilder,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function generateBundle(Service $service, string $blockType): string
|
public function generateBundle(Service $service, string $blockType): string
|
||||||
|
|
@ -164,12 +163,6 @@ private function buildBundleFromSlides(
|
||||||
];
|
];
|
||||||
|
|
||||||
$song = ProFileGenerator::generate($groupName, $groups, $arrangements);
|
$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';
|
$proFilename = self::safeFilename($groupName).'.pro';
|
||||||
|
|
||||||
$bundle = new PresentationBundle($song, $proFilename, $mediaFiles);
|
$bundle = new PresentationBundle($song, $proFilename, $mediaFiles);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ class ProExportService
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MacroResolutionService $macroResolutionService,
|
private readonly MacroResolutionService $macroResolutionService,
|
||||||
private readonly ServiceImageResolver $imageResolver,
|
private readonly ServiceImageResolver $imageResolver,
|
||||||
private readonly SlideMediaElementBuilder $slideMediaElementBuilder,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function generateProFile(Song $song, ?Service $service = null, ?SongArrangement $selected = null): string
|
public function generateProFile(Song $song, ?Service $service = null, ?SongArrangement $selected = null): string
|
||||||
|
|
@ -37,11 +36,6 @@ public function generateParserSong(Song $song, ?Service $service = null, ?SongAr
|
||||||
$this->buildCcliMetadata($song),
|
$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
|
// 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.
|
// per-service selection while keeping ALL arrangements available in the .pro file.
|
||||||
$chosen = $this->chosenArrangement($song, $selected);
|
$chosen = $this->chosenArrangement($song, $selected);
|
||||||
|
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
<?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": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git"
|
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/socialite": "^5.24",
|
"laravel/socialite": "^5.24",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"bussnet/propresenter7-php-lib": "dev-master",
|
"propresenter/parser": "dev-master",
|
||||||
"spatie/pdf-to-image": "^1.2",
|
"spatie/pdf-to-image": "^1.2",
|
||||||
"tightenco/ziggy": "^2.0"
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "8ddc03457b69ce825f98a92c19a0204e",
|
"content-hash": "87837501106e784aa10ddd7743056cba",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "5pm-hdh/churchtools-api",
|
"name": "5pm-hdh/churchtools-api",
|
||||||
|
|
@ -191,77 +191,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-02-10T14:33:43+00:00"
|
"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",
|
"name": "carbonphp/carbon-doctrine-types",
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
|
|
@ -3884,6 +3813,33 @@
|
||||||
],
|
],
|
||||||
"time": "2026-01-27T09:17:28+00:00"
|
"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",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
@ -10906,7 +10862,7 @@
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": {
|
"stability-flags": {
|
||||||
"bussnet/propresenter7-php-lib": 20
|
"propresenter/parser": 20
|
||||||
},
|
},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
|
|
|
||||||
851
package-lock.json
generated
851
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,14 +14,10 @@
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProPlaylistReader;
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
use Rv\Data\Cue\CompletionActionType;
|
|
||||||
use Rv\Data\Cue\CompletionTargetType;
|
|
||||||
use Tests\Support\InspectsSlideFillMedia;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class AbspannExportTest extends TestCase
|
final class AbspannExportTest extends TestCase
|
||||||
{
|
{
|
||||||
use InspectsSlideFillMedia;
|
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -57,62 +53,31 @@ public function test_abspann_is_last_item_with_keyvisual_first_then_information_
|
||||||
$slides = $this->allParserSlides($abspann);
|
$slides = $this->allParserSlides($abspann);
|
||||||
$this->assertCount(3, $slides);
|
$this->assertCount(3, $slides);
|
||||||
|
|
||||||
// First cue is the key-visual as a BACKGROUND media action (no content element).
|
// First cue is the key-visual (image-only background media).
|
||||||
$this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual');
|
$this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual');
|
||||||
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
|
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
|
||||||
$this->assertSame([], $this->fillMediaUrls($slides[0]), 'Key-visual cue has no content image element');
|
$this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media');
|
||||||
$this->assertFalse($this->hasForegroundImageMediaAction($slides[0]), 'No foreground media action may remain');
|
|
||||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||||
|
|
||||||
// Remaining cues are the information slides as content image elements, labelled by filename.
|
// Remaining cues are the information slides (foreground media, labelled by filename).
|
||||||
$this->assertNotEmpty($this->fillMediaUrls($slides[1]));
|
$this->assertTrue($slides[1]->hasMedia());
|
||||||
$this->assertNotContains('KEY_VISUAL.jpg', $this->fillMediaUrls($slides[1]));
|
|
||||||
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
||||||
$this->assertFalse($this->hasImageMediaAction($slides[1]));
|
|
||||||
$this->assertSame('info1.jpg', $slides[1]->getLabel());
|
$this->assertSame('info1.jpg', $slides[1]->getLabel());
|
||||||
$this->assertNotEmpty($this->fillMediaUrls($slides[2]));
|
$this->assertTrue($slides[2]->hasMedia());
|
||||||
$this->assertSame('info2.jpg', $slides[2]->getLabel());
|
$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']);
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-advance (10s), loop-to-first and the Dissolve transition survive the disk
|
* Auto-advance (10s), loop-to-first and the Dissolve transition are emitted into
|
||||||
* round-trip and are asserted in
|
* the slideData by addAbspannPresentation and are covered by the parser repo's
|
||||||
* test_abspann_is_last_item_with_keyvisual_first_then_information_slides. This test
|
* in-memory ProFileGeneratorCompletionTest. They are intentionally NOT asserted
|
||||||
* stays focused on the per-cue Abspann macro assignment.
|
* 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.
|
||||||
*/
|
*/
|
||||||
public function test_abspann_cues_carry_configured_abspann_macro(): void
|
public function test_abspann_cues_carry_configured_abspann_macro(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProBundleReader;
|
use ProPresenter\Parser\ProBundleReader;
|
||||||
use Tests\Support\InspectsSlideFillMedia;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +19,6 @@
|
||||||
*/
|
*/
|
||||||
final class ExportBundleRelativeMediaTest extends TestCase
|
final class ExportBundleRelativeMediaTest extends TestCase
|
||||||
{
|
{
|
||||||
use InspectsSlideFillMedia;
|
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -126,17 +124,15 @@ public function test_probundle_export_referenziert_vordergrund_medien_bundle_rel
|
||||||
$bundle = ProBundleReader::read($bundlePath);
|
$bundle = ProBundleReader::read($bundlePath);
|
||||||
$slides = $bundle->getSong()->getSlides();
|
$slides = $bundle->getSong()->getSlides();
|
||||||
|
|
||||||
// The foreground image is now a content fill.media element, not a media ACTION.
|
$mediaSlides = array_filter($slides, fn ($slide) => $slide->hasMedia());
|
||||||
$mediaSlides = array_filter($slides, fn ($slide) => $this->fillMediaUrls($slide) !== []);
|
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-media slide in the bundle.');
|
||||||
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-image content slide in the bundle.');
|
|
||||||
|
|
||||||
foreach ($mediaSlides as $slide) {
|
foreach ($mediaSlides as $slide) {
|
||||||
$this->assertFalse($this->hasImageMediaAction($slide), 'No image media ACTION may remain.');
|
$url = $slide->getMediaUrl();
|
||||||
foreach ($this->fillMediaUrls($slide) as $url) {
|
$this->assertNotNull($url);
|
||||||
// Bundle-relative resources use a bare basename (no absolute path / no slash).
|
// 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->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@unlink($bundlePath);
|
@unlink($bundlePath);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
<?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,12 +13,10 @@
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProPlaylistReader;
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
use Tests\Support\InspectsSlideFillMedia;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class FullPlaylistExportTest extends TestCase
|
final class FullPlaylistExportTest extends TestCase
|
||||||
{
|
{
|
||||||
use InspectsSlideFillMedia;
|
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -116,12 +114,8 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
|
||||||
$songSlides = $this->allParserSlides($songParser);
|
$songSlides = $this->allParserSlides($songParser);
|
||||||
$this->assertNotEmpty($songSlides);
|
$this->assertNotEmpty($songSlides);
|
||||||
foreach ($songSlides as $slide) {
|
foreach ($songSlides as $slide) {
|
||||||
// Service background stays a BACKGROUND media action behind the song text.
|
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media');
|
||||||
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background action');
|
|
||||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
$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();
|
$embeddedMedia = $playlist->getEmbeddedMediaFiles();
|
||||||
|
|
@ -135,13 +129,7 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
|
||||||
$sermonSlides = $this->allParserSlides($sermonParser);
|
$sermonSlides = $this->allParserSlides($sermonParser);
|
||||||
$this->assertCount(2, $sermonSlides);
|
$this->assertCount(2, $sermonSlides);
|
||||||
foreach ($sermonSlides as $slide) {
|
foreach ($sermonSlides as $slide) {
|
||||||
// Uploaded sermon image is a content element with the service background
|
$this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background media');
|
||||||
// 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());
|
$this->assertSame($slideCountBefore, Slide::count());
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,10 @@
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProPlaylistReader;
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
use Tests\Support\InspectsSlideFillMedia;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class KeyVisualFallbackTest extends TestCase
|
final class KeyVisualFallbackTest extends TestCase
|
||||||
{
|
{
|
||||||
use InspectsSlideFillMedia;
|
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -155,11 +153,7 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_
|
||||||
|
|
||||||
$slides = $this->allParserSlides($sermonSong);
|
$slides = $this->allParserSlides($sermonSong);
|
||||||
$this->assertCount(1, $slides);
|
$this->assertCount(1, $slides);
|
||||||
// Sermon slide is a foreground content image; no service background configured → no background layer.
|
|
||||||
$this->assertFalse($slides[0]->hasBackgroundMedia());
|
$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']);
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,10 @@
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\PlaylistArchive;
|
use ProPresenter\Parser\PlaylistArchive;
|
||||||
use ProPresenter\Parser\ProPlaylistReader;
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
use Tests\Support\InspectsSlideFillMedia;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class PlaylistSequenceTest extends TestCase
|
final class PlaylistSequenceTest extends TestCase
|
||||||
{
|
{
|
||||||
use InspectsSlideFillMedia;
|
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -68,18 +66,12 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
|
||||||
$introSlides = $this->slidesForEntry($playlist, $entries[$offset]);
|
$introSlides = $this->slidesForEntry($playlist, $entries[$offset]);
|
||||||
$this->assertCount(2, $introSlides);
|
$this->assertCount(2, $introSlides);
|
||||||
|
|
||||||
// Key-visual is a BACKGROUND media action on both intro cues (no content element).
|
|
||||||
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
||||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
$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());
|
$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->assertTrue($introSlides[1]->hasBackgroundMedia());
|
||||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
|
$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
|
// 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.
|
// role is a separate smaller, non-bold (\b0\fs50) subtitle run.
|
||||||
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
||||||
|
|
@ -297,14 +289,10 @@ 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.
|
// The intro .pro carries both cues: key-visual alone + key-visual with name tag.
|
||||||
$introSlides = $this->slidesForEntry($playlist, $entries[0]);
|
$introSlides = $this->slidesForEntry($playlist, $entries[0]);
|
||||||
$this->assertCount(2, $introSlides);
|
$this->assertCount(2, $introSlides);
|
||||||
// Key-visual is a BACKGROUND media action on both intro cues (no content element).
|
|
||||||
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
||||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
||||||
$this->assertSame([], $this->fillMediaUrls($introSlides[0]));
|
|
||||||
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
|
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
|
||||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
|
$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]);
|
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
||||||
$this->assertSame('Erika Predigt', $name);
|
$this->assertSame('Erika Predigt', $name);
|
||||||
$this->assertSame('Predigt', $subtitle);
|
$this->assertSame('Predigt', $subtitle);
|
||||||
|
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
<?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,12 +18,10 @@
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProBundleReader;
|
use ProPresenter\Parser\ProBundleReader;
|
||||||
use ProPresenter\Parser\ProPlaylistReader;
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
use Tests\Support\InspectsSlideFillMedia;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class ProFileExportTest extends TestCase
|
final class ProFileExportTest extends TestCase
|
||||||
{
|
{
|
||||||
use InspectsSlideFillMedia;
|
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private function createSongWithContent(): Song
|
private function createSongWithContent(): Song
|
||||||
|
|
@ -294,13 +292,9 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
|
||||||
|
|
||||||
$this->assertNotEmpty($slides);
|
$this->assertNotEmpty($slides);
|
||||||
foreach ($slides as $slide) {
|
foreach ($slides as $slide) {
|
||||||
// Service background stays a BACKGROUND media action behind the song text.
|
|
||||||
$this->assertTrue($slide->hasBackgroundMedia());
|
$this->assertTrue($slide->hasBackgroundMedia());
|
||||||
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
||||||
// Text is a content element; no image content element, no foreground action.
|
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
|
||||||
$this->assertTrue($this->elementHasTextAt($slide, 0));
|
|
||||||
$this->assertSame([], $this->fillMediaUrls($slide));
|
|
||||||
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,10 +361,7 @@ public function test_export_ohne_background_enthaelt_keine_background_actions():
|
||||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||||
|
|
||||||
foreach ($this->allParserSlides($parserSong) as $slide) {
|
foreach ($this->allParserSlides($parserSong) as $slide) {
|
||||||
// No background configured → no background fill.media element and no media ACTION.
|
|
||||||
$this->assertFalse($slide->hasBackgroundMedia());
|
$this->assertFalse($slide->hasBackgroundMedia());
|
||||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
|
||||||
$this->assertFalse($this->hasImageMediaAction($slide));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,18 +398,9 @@ public function test_sermon_export_ueberspringt_background_bei_full_cover_folien
|
||||||
$slides = $this->allParserSlides($bundle->getSong());
|
$slides = $this->allParserSlides($bundle->getSong());
|
||||||
|
|
||||||
$this->assertCount(2, $slides);
|
$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->assertTrue($slides[0]->hasBackgroundMedia());
|
||||||
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
|
$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->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->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name');
|
||||||
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
|
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
|
||||||
|
|
||||||
|
|
@ -455,9 +437,7 @@ public function test_information_und_moderation_exports_erhalten_keinen_backgrou
|
||||||
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
|
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
|
||||||
|
|
||||||
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
|
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->assertFalse($slide->hasBackgroundMedia());
|
||||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@unlink($bundlePath);
|
@unlink($bundlePath);
|
||||||
|
|
@ -524,21 +504,12 @@ public function test_playlist_export_setzt_background_auf_sermon_folien_und_nich
|
||||||
|
|
||||||
foreach ($this->allParserSlides($informationSong) as $slide) {
|
foreach ($this->allParserSlides($informationSong) as $slide) {
|
||||||
$this->assertFalse($slide->hasBackgroundMedia());
|
$this->assertFalse($slide->hasBackgroundMedia());
|
||||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$sermonSlides = $this->allParserSlides($sermonSong);
|
$sermonSlides = $this->allParserSlides($sermonSong);
|
||||||
$this->assertCount(2, $sermonSlides);
|
$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->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->assertFalse($sermonSlides[1]->hasBackgroundMedia());
|
||||||
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[1]));
|
|
||||||
|
|
||||||
$this->cleanupTempDir($result['temp_dir']);
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
<?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.
|
# und database/ gitignored sind und nicht angefasst werden.
|
||||||
git reset --hard origin/master
|
git reset --hard origin/master
|
||||||
|
|
||||||
# Die bussnet/propresenter7-php-lib-Version wird ausschliesslich ueber die eingecheckte
|
# Die propresenter/parser-Version wird ausschliesslich ueber die eingecheckte
|
||||||
# composer.lock gepinnt (Standard-Composer-Praxis). Der Dockerfile-Schritt
|
# composer.lock gepinnt (Standard-Composer-Praxis). Der Dockerfile-Schritt
|
||||||
# "composer install" deployt exakt diesen Lock-Stand; "COPY composer.json
|
# "composer install" deployt exakt diesen Lock-Stand; "COPY composer.json
|
||||||
# composer.lock" bricht die Composer-Layer automatisch, sobald sich die Lock
|
# composer.lock" bricht die Composer-Layer automatisch, sobald sich die Lock
|
||||||
# aendert. WICHTIG: composer.lock committen, wenn der Parser fortschreitet.
|
# aendert. WICHTIG: composer.lock committen, wenn der Parser fortschreitet.
|
||||||
# (Kein "composer update" hier: master des Parsers wurde umbenannt zu
|
# (Kein "composer update" hier: master des Parsers wurde umbenannt zu
|
||||||
# bussnet/propresenter7-php-lib, ein Re-Resolve von dev-master schlaegt fehl.)
|
# bussnet/propresenter7-php-api, ein Re-Resolve von dev-master schlaegt fehl.)
|
||||||
|
|
||||||
echo "[deploy] Baue Image (inkl. aktualisierter Basis-Images) ..."
|
echo "[deploy] Baue Image (inkl. aktualisierter Basis-Images) ..."
|
||||||
$DC build --pull
|
$DC build --pull
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Toggle bussnet/propresenter7-php-lib between remote VCS and local path repository.
|
# Toggle propresenter/parser between remote VCS and local path repository.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./use_local_pp_lib.sh <path> — Switch to local checkout (symlinked)
|
# ./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");
|
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||||
'
|
'
|
||||||
|
|
||||||
if [[ -L "vendor/bussnet/propresenter7-php-lib" ]] || [[ -d "vendor/bussnet/propresenter7-php-lib" ]]; then
|
if [[ -L "vendor/propresenter/parser" ]] || [[ -d "vendor/propresenter/parser" ]]; then
|
||||||
rm -rf vendor/bussnet/propresenter7-php-lib
|
rm -rf vendor/propresenter/parser
|
||||||
fi
|
fi
|
||||||
|
|
||||||
composer update bussnet/propresenter7-php-lib
|
composer update propresenter/parser
|
||||||
echo ""
|
echo ""
|
||||||
echo "bussnet/propresenter7-php-lib wird jetzt vom Remote-Repository geladen."
|
echo "propresenter/parser wird jetzt vom Remote-Repository geladen."
|
||||||
}
|
}
|
||||||
|
|
||||||
switch_to_local() {
|
switch_to_local() {
|
||||||
|
|
@ -63,8 +63,8 @@ switch_to_local() {
|
||||||
|
|
||||||
local PKG_NAME
|
local PKG_NAME
|
||||||
PKG_NAME=$(php -r "echo json_decode(file_get_contents('$LOCAL_PATH/composer.json'), true)['name'] ?? '';")
|
PKG_NAME=$(php -r "echo json_decode(file_get_contents('$LOCAL_PATH/composer.json'), true)['name'] ?? '';")
|
||||||
if [[ "$PKG_NAME" != "bussnet/propresenter7-php-lib" ]]; then
|
if [[ "$PKG_NAME" != "propresenter/parser" ]]; then
|
||||||
echo "Error: Package in $LOCAL_PATH ist '$PKG_NAME', erwartet 'bussnet/propresenter7-php-lib'." >&2
|
echo "Error: Package in $LOCAL_PATH ist '$PKG_NAME', erwartet 'propresenter/parser'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -88,13 +88,13 @@ switch_to_local() {
|
||||||
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||||
' -- "$REL_PATH"
|
' -- "$REL_PATH"
|
||||||
|
|
||||||
if [[ -L "vendor/bussnet/propresenter7-php-lib" ]] || [[ -d "vendor/bussnet/propresenter7-php-lib" ]]; then
|
if [[ -L "vendor/propresenter/parser" ]] || [[ -d "vendor/propresenter/parser" ]]; then
|
||||||
rm -rf vendor/bussnet/propresenter7-php-lib
|
rm -rf vendor/propresenter/parser
|
||||||
fi
|
fi
|
||||||
|
|
||||||
composer update bussnet/propresenter7-php-lib
|
composer update propresenter/parser
|
||||||
echo ""
|
echo ""
|
||||||
echo "bussnet/propresenter7-php-lib ist jetzt mit $REL_PATH verlinkt (Symlink)."
|
echo "propresenter/parser ist jetzt mit $REL_PATH verlinkt (Symlink)."
|
||||||
echo "Aenderungen in $REL_PATH sind sofort verfuegbar."
|
echo "Aenderungen in $REL_PATH sind sofort verfuegbar."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue