Compare commits

..

No commits in common. "0a345aa3b265963be37b1e5a514e343a2d115a7d" and "a10068e7835b88b4392b7e08aaedbc9be9792b64" have entirely different histories.

83 changed files with 280 additions and 4229 deletions

View file

@ -1,8 +0,0 @@
ls tests/fixtures/ccli/*.txt | wc -l
22
grep -rl "Strophe\|Refrain" tests/fixtures/ccli/ | wc -l
4
grep -rl "(Repeat)" tests/fixtures/ccli/ | wc -l
2

View file

@ -1,9 +0,0 @@
ddev exec php artisan test --filter=CcliFixtureSanityTest
PASS Tests\Feature\CcliFixtureSanityTest
✓ ccli fixture corpus has at least 20 txt files
✓ each ccli fixture is valid utf8 with section labels and title
✓ ccli fixture corpus covers german labels
✓ ccli fixture corpus covers repeat markers
Tests: 4 passed (160 assertions)

View file

@ -1,7 +0,0 @@
Command: ddev exec php artisan test --filter=CcliLabelsTest
Result: PASS
Assertions: 71
Tests: 55 passed
Command: ddev exec ./vendor/bin/pint --test app/Support/CcliLabels.php tests/Unit/CcliLabelsTest.php
Result: PASS

View file

@ -1,6 +0,0 @@
Command: ddev exec php artisan tinker --execute="var_export([App\\Support\\CcliLabels::normalizeLabelName('Foobar'), App\\Support\\CcliLabels::normalizeLabelName('')]);"
Result:
array (
0 => 'Foobar',
1 => '',
)

View file

@ -1 +0,0 @@
CAUGHT: Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.

View file

@ -1,5 +0,0 @@
array:3 [
"title" => "Test Song 3"
"sections" => 2
"has_translation" => true
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:4

View file

@ -1,4 +0,0 @@
array:2 [
"repeat_sections" => 1
"modifier" => "Repeat"
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:3

View file

@ -1,4 +0,0 @@
array:2 [
"title" => "Test Song 15"
"has_umlauts" => true
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:3

View file

@ -1,10 +0,0 @@
Command: ddev exec php artisan test --filter='blocks active duplicate ccli_id with DuplicateCcliSongException'
PASS: Active duplicate CCLI-ID is blocked.
Verified assertions:
- first import succeeds
- second import throws App\Exceptions\DuplicateCcliSongException
- exception existingSongId matches original Song ID
- exception message contains "existiert bereits"
- Song::count() remains 1

View file

@ -1,13 +0,0 @@
Command: ddev exec php artisan test --filter='imports english-only fixture and creates song with default arrangement'
PASS: CcliImportService imported tests/fixtures/ccli/english-only-multi-verse.txt
Verified assertions:
- status = created
- Song title = Test Song 1
- Song ccli_id = 9999001
- imported_from_ccli_at is set
- ccli_source_url = https://songselect.ccli.com/Songs/9999001
- default arrangement name = normal, is_default = true
- arrangement label entries = 5
- slide rows = 9

View file

@ -1,9 +0,0 @@
Command: ddev exec php artisan test --filter='restores soft-deleted song and does not duplicate normal arrangement'
PASS: Soft-deleted CCLI match is restored instead of duplicated.
Verified assertions:
- restored import status = restored
- returned Song ID matches original soft-deleted Song ID
- restored song is no longer trashed
- only one normal arrangement exists after restore/import

View file

@ -1,9 +0,0 @@
Command: ddev exec php artisan test --filter='rolls back song and log when slide creation fails'
PASS: Transaction rollback verified via SQLite trigger failure on song_slides insert.
Verified assertions:
- import throws Illuminate\Database\QueryException
- Song::count() = 0 after failure
- SongSlide::count() = 0 after failure
- ApiRequestLog::count() = 0 after failure

View file

@ -1,17 +0,0 @@
Task 8 evidence: German local labels pair with English CCLI labels.
Command run:
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
Relevant passing test:
✓ pairs German local labels with English CCLI labels via normalization
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
- Local "Strophe 1" normalizes to canonical "verse 1" and matches CCLI "Verse 1".
- Local "Refrain" normalizes to canonical "chorus" and matches CCLI "Chorus".
- unmatched_labels is empty.
- mapping has three entries.
Full targeted result:
Tests: 5 passed (20 assertions)
Duration: 0.46s

View file

@ -1,18 +0,0 @@
Task 8 evidence: unmatched local sections are returned for UI review.
Command run:
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
Relevant passing test:
✓ returns unmatched_labels for sections not in CCLI
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
- Local arrangement contains Verse 1, Chorus, Bridge.
- CCLI paste contains Verse 1 and Chorus only.
- result['unmatched_labels'] contains "Bridge".
- mapping still has all three local labels.
- Bridge mapping has ccli_label = null and empty distributed line placeholders.
Full targeted result:
Tests: 5 passed (20 assertions)
Duration: 0.46s

View file

@ -1,35 +0,0 @@
# CCLI SongSelect Import — Issues
## [2026-05-10] Known Issues / Gotchas
### T1: Fixture corpus requires structurally accurate CCLI format
- Plan originally asked for "real CCLI text pastes" but since we can't access SongSelect, agent creates synthetic fixtures.
- Synthetic fixtures MUST match exact CCLI format: title line, blank, section label, lyrics, blank, footer with © and CCLI #.
- The parser tests depend on these fixtures — structural accuracy is critical.
### T7: ProImportService method name
- Plan was corrected: the public method is `ProImportService::import(UploadedFile $file)`, not `upsertSong()`.
- When mirroring the pattern, READ app/Services/ProImportService.php before implementing.
### No Admin Role
- No `admin` role or Policy exists in the codebase.
- CCLI Settings section is visible to ALL authenticated users.
- Document this decision, don't create a policy gate.
### AGENDA_KEYS whitelist
- SettingsController has a `const AGENDA_KEYS` array.
- T4 MUST add `'default_translation_language'` to this array OR update the validation to include it.
- Failure to update AGENDA_KEYS = PATCH /settings will silently ignore the new key.
### Translation Pairing Label Direction
- CCLI paste can have English labels; local songs may have German labels (Strophe, Refrain).
- CcliLabels::normalizeLabelName() normalizes BOTH directions to canonical English before pairing.
- Do NOT assume same language on both sides.
### T7: Global Label Slide Replacement Caveat
- The requested CCLI import pattern deletes `songSlides()` on the resolved global `Label` before recreating slides.
- Because labels are shared globally, a later import using the same canonical label name replaces that label's slide text globally; this intentionally matches the task spec and existing `ProImportService` pattern, but remains a design caveat for future per-song slide ownership work.
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
- Missing labels are returned in `unmatched_labels`, and their mapping entries keep empty slide placeholders so `distributed_text` still aligns with the local arrangement shape.

View file

@ -1,110 +0,0 @@
# CCLI SongSelect Import — Learnings
## [2026-05-10] Session Start
### Architecture Decisions
- Manual paste + bookmarklet approach (NO server-side scraping — Cloudflare/ToS blocker)
- CcliPasteParser is closure-injectable (mirrors ChurchToolsService pattern for testability)
- All songs upserted via CcliImportService mirroring ProImportService::import() shape
- Translation stored inline on SongSlide.text_content_translated (no separate model)
- default_translation_language = APP-GLOBAL Setting (not per-user)
### Key Codebase Facts
- Song.ccli_id is UNIQUE indexed nullable — primary CCLI match key
- SongSlide: text_content (original), text_content_translated (translation)
- Labels are GLOBAL (shared across all songs) — labels table with name + color
- ProImportService::import() is the template for upsert (not upsertSong — that method doesn't exist)
- SettingsController::AGENDA_KEYS constant whitelist for Settings KV
- TranslationService::importFromText distributes lines preserving local slide line counts
- ArrangementDialog.vue lines 488-532 = searchable song select (where CCLI buttons go)
### CCLI SongSelect "View Lyrics" Page Format
- Title on first non-empty line
- Section label as standalone line (e.g., "Verse 1", "Chorus")
- Lyrics lines under each section
- Footer: copyright (©), CCLI number (e.g., "CCLI # 1234567"), author
### Section Label Regex (English + German + variants)
```
/^(Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?(\s*\((?:Repeat|Wdh\.?)\))?(\s*[xX]\s*\d+)?$/i
```
### Language Mapping (EN ↔ DE)
- Verse ↔ Strophe
- Chorus ↔ Refrain
- Bridge ↔ Brücke
- Pre-Chorus ↔ Vorrefrain
- Ending ↔ Schluss
- Interlude ↔ Zwischenspiel
### Test Fixture Format
Fixtures are synthetic CCLI-format text files. Format:
```
Test Song Title
Test Artist
Verse 1
Line 1 of verse
Line 2 of verse
Chorus
Chorus line 1
Chorus line 2
© 2024 Test Publishing
CCLI # 9999001
```
### Fixture Corpus Notes
- Keep fixture titles/artists anonymized and numeric (`Test Song N`, `Test Artist N`)
- Include both English and German section labels in the corpus so parser regex coverage stays broad
- Add edge cases for missing footer pieces, whitespace, repeat markers, and suffix labels (`2a`, `x2`, `(Repeat)`)
### 2026-05-10 CCLI Label Utility Notes
- `CcliLabels` works best with a fixed kind list in regexes; no locale config needed for EN/DE normalization.
- `normalizeLabelName()` should map only known German kinds and preserve any numeric suffix.
- `parseLabel()` can stay lightweight by returning `null` for non-labels and a small array for matched labels.
### 2026-05-10 Song CCLI Metadata Migration
- Song CCLI metadata belongs on `songs` as nullable fields: `imported_from_ccli_at` (timestamp) + `ccli_source_url` (string 500).
- Factory state helpers can stay tiny; `fromCcli()` just seeds timestamp + SongSelect URL.
- Inference/LSP can lag after edits; a tiny no-op signature change (`fn (): array => [...]`) forced the factory diagnostics to refresh cleanly.
### 2026-05-10 Settings Language Seed
- `SettingsController::AGENDA_KEYS` drives both the index props and the allowed `key` values for PATCH updates.
- `default_translation_language` should be validated as a whitelist value (`DE|EN|FR|ES|NL|IT`) only when that setting is being updated.
- `CcliSettingsSeeder` must use `Setting::firstOrCreate()` so reseeding does not overwrite a user-changed language.
### 2026-05-10 CcliPasteParser Scaffold
- Mirror `ChurchToolsService` with nullable `Closure` constructor injections and default `= null` values.
- This codebase uses `App\Services\DTO\...` namespaces/directories for DTOs, so keep the uppercase `DTO` path aligned with existing services.
- Scaffold tests can verify Laravel container resolution without adding any service provider binding.
### 2026-05-10 CcliPasteParser Implementation
- Parser trims pasted lines, treats blank lines as separators, extracts first two header lines as title/author, and excludes CCLI metadata from lyric sections.
- EN/DE side-by-side imports merge only adjacent labels with different raw kinds but the same `CcliLabels::normalizeLabelName()` canonical kind/number, preserving German lyrics in `linesTranslated`.
- DDEV/Linux path is `tests/fixtures/ccli` (lowercase); macOS accepted `tests/Fixtures/ccli`, but tests must use lowercase for container portability.
### 2026-05-10 CcliImportService Implementation
- `CcliImportService` mirrors `ProImportService` by wrapping song metadata, global label resolution, slide replacement, default arrangement upsert, arrangement-label recreation, and `ApiRequestLog` success entry in one `DB::transaction()`.
- Active duplicate CCLI IDs are blocked with `DuplicateCcliSongException`; trashed matches are restored and updated in-place via `Song::withTrashed()->where('ccli_id', ...)`.
- CCLI label names should be canonicalized with `CcliLabels::normalizeLabelName($kind.' '.$number)` before `Label::firstOrCreate()`, keeping labels global/shared.
- Import tests can verify rollback deterministically with a temporary SQLite trigger that aborts `song_slides` insert; this proves song + log rows are not persisted after mid-transaction failure.
### 2026-05-10 CCLI Parser Review Fixes
- CCLI SongSelect metadata can appear as `CCLI Song #`, `CCLI-Nr.` or `CCLI-Liednummer`; extraction must ignore `CCLI License/Lizenz` numbers.
- Parsed section `kind` is canonicalized via `CcliLabels::normalizeLabelName()`, while the original pasted label remains available in `label`; translation pairing still compares raw label kinds internally.
### 2026-05-10 CCLI Translation Pairing
- `CcliTranslationPairingService` returns a review-only mapping and never writes `SongSlide.text_content_translated`; callers remain responsible for persistence.
- Pairing canonicalizes both local arrangement labels and CCLI sections with `CcliLabels::normalizeLabelName()` + lowercase, so `Strophe 1``Verse 1` and `Refrain``Chorus` work across languages.
- Distribution mirrors `TranslationService::importTranslation()` by filling local slide slots in arrangement order using each local slide's original line count; overflow CCLI lines are kept on the final local slide for that section.
### 2026-05-11 CcliPasteController (T10)
- `SongMatchingService::manualAssign(ServiceSong, Song)` takes a **Song object** (not int id) — different from initial task plan.
- API routes use `auth:sanctum` middleware (not just `auth`); Sanctum's `EnsureFrontendRequestsAreStateful` is prepended globally to API in bootstrap/app.php.
- Apply `throttle:30,1` via a nested `Route::middleware('throttle:30,1')->group(...)` inside the existing sanctum group; combined middleware shown by `route:list -vv`.
- `assertInertia()` enforces page-component file existence by default. For pages whose Vue component is created in a later task (T16 `Songs/ImportFromCcliPaste`), pass `$shouldExist=false` to `component()` as second arg.
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.

View file

@ -98,48 +98,6 @@ ## SongDB Import
---
## CCLI Import
Songs can be imported from CCLI SongSelect via a paste-based flow (no server-side scraping — ToS-compliant).
### How it works
1. User opens a song on **songselect.ccli.com** in their browser (must be logged in with their CCL account)
2. User copies the full page text (Ctrl+A → Ctrl+C)
3. User clicks "Aus CCLI importieren" in the SongDB or service form, pastes the text, clicks "Vorschau", then "Importieren"
Alternatively, install the **browser bookmarklet** from Settings → CCLI Import. The bookmarklet automates step 2-3 in two clicks.
### Key files
| File | Purpose |
|------|---------|
| `app/Services/CcliPasteParser.php` | Pure-PHP parser: extracts title/author/CCLI-Nr/year/sections from pasted text |
| `app/Services/CcliImportService.php` | Upserts Song + Arrangement + Labels + Slides; handles duplicates and soft-delete restore |
| `app/Services/CcliTranslationPairingService.php` | Auto-pairs CCLI sections with local song labels for translation import |
| `app/Support/CcliLabels.php` | Section-label regex + EN↔DE name mapping (Verse↔Strophe, Chorus↔Refrain, etc.) |
| `app/Http/Controllers/CcliPasteController.php` | POST /api/ccli/preview + POST /api/songs/import-from-ccli-paste (3 modes) |
| `app/Http/Controllers/BookmarkletController.php` | GET /bookmarklets/ccli-import.js — serves the bookmarklet JS |
| `resources/js/Components/CcliPasteDialog.vue` | Modal: textarea → preview → import buttons (surface-aware) |
| `resources/js/Pages/Songs/ImportFromCcliPaste.vue` | Bookmarklet redirect landing page |
| `tests/Fixtures/ccli/` | 22 synthetic CCLI-format fixture files for parser tests |
### Test coverage
- `tests/Feature/CcliPasteParserTest.php` — parser against all 22 fixtures
- `tests/Feature/CcliImportServiceTest.php` — upsert, duplicate, restore, transaction
- `tests/Feature/CcliTranslationPairingServiceTest.php` — label pairing, line distribution
- `tests/Feature/CcliPasteControllerTest.php` — API endpoints, auth, throttle
- `tests/e2e/ccli-paste-import.spec.ts` — SongDB import flow
- `tests/e2e/ccli-bookmarklet.spec.ts` — bookmarklet endpoint + Settings page
- `tests/e2e/ccli-translation-pairing.spec.ts` — Translate.vue prefill
### Maintenance note
If SongSelect changes their HTML structure, update the bookmarklet DOM selectors in `resources/js/bookmarklet/ccli-import.ts` (or the inline JS in `BookmarkletController.php`). The server-side parser (`CcliPasteParser.php`) is independent of SongSelect's HTML — it only parses the plain-text lyrics format.
---
## Repository Structure
Two git repositories, both local (no remote):

View file

@ -1,429 +0,0 @@
# CCLI SongSelect Partner API — Doc Pointer
## Where to get the docs
**Postman documentation** (only public source, no PDF/OpenAPI mirror):
https://documenter.getpostman.com/view/604633/TzseGkmA
The page is JS-rendered. Two ways to read it:
1. Open in a browser (Chrome/Firefox), wait for the Postman documenter to render.
2. Click the "Run in Postman" button top-right to import the full collection + environment into a Postman workspace — then inspect every endpoint, params, headers, sample requests/responses.
The collection name is **"SongSelect Partner API"** under owner id `604633`.
## Status (read first!)
> **NOTICE: CCLI has retired the SongSelect API Partner Program and is no longer accepting new API partners.**
Existing partners keep working. New access requires contacting CCLI directly (`partners@ccli.com` / regional CCLI office) to request reinstatement or special arrangement.
## Key facts (from the docs)
- **Auth**: OpenID Connect / OAuth 2.0, **Authorization Code with PKCE**, refresh tokens supported
- Authorize: `https://identityservices.ccli.com/connect/authorize`
- Token: `https://identityservices.ccli.com/connect/token`
- Scope: `openid cclipartnerapi.read offline_access`
- **Subscription Key**: every request needs header `Ocp-Apim-Subscription-Key: <key>` (dev key for testing, prod key for live)
- **Tokens**: access token 1h, refresh token 60-day sliding (one-time use, new refresh returned on each refresh)
- **Rate limits**: 100 calls / 10s short term, 300 calls / 5min long term. `429` returns JSON `{statusCode, message}`.
- **Dev restrictions**: dev client only sees content for users linked to the "SongSelect API <country> Partners" test organization.
- Endpoint reference (search, song detail, lyrics, chord chart, etc.) lives inside the Postman collection — load it to see exact paths/params, not summarized in the public preview.
## Credentials needed before coding
1. CCLI Partner ClientId + ClientSecret
2. Development Subscription Key (Ocp-Apim-Subscription-Key)
3. Production Subscription Key (later)
4. A CCLI user account linked to the Partner test organization (for dev refresh-token bootstrap)
Store in `.env`:
```
CCLI_PARTNER_CLIENT_ID=
CCLI_PARTNER_CLIENT_SECRET=
CCLI_PARTNER_SUBSCRIPTION_KEY_DEV=
CCLI_PARTNER_SUBSCRIPTION_KEY_PROD=
CCLI_PARTNER_REDIRECT_URI=https://pp-planer.ddev.site/oauth/ccli/callback
```
## Bootstrap flow for a new agent
1. Load Postman collection from URL above → list every endpoint with its path, params, sample response.
2. Mirror existing `ChurchToolsService` pattern (`app/Services/ChurchToolsService.php`) — closure-injectable fetcher, `logApiCall`, `classifyError`, German error messages, `ApiRequestLog` row per call.
3. Implement OAuth2 PKCE handshake → persist refresh token (encrypted) in a `ccli_tokens` table. Auto-refresh on 401.
4. Always send `Ocp-Apim-Subscription-Key` header alongside `Authorization: Bearer <access_token>`.
5. Respect rate limits (Laravel `RateLimiter::for('ccli', ...)` with 100/10s + 300/5min buckets).
6. Map result to existing schema: `Song.ccli_id`, arrangements + global `Label`s (Strophe 1 / Refrain / Bridge), `SongSlide.text_content`. See `ProImportService::upsertSong` for the upsert template.
## Fallback if API access denied
- Manual paste flow → parser splits on `Verse N`, `Chorus`, `Bridge`, `Pre-Chorus`, `Tag`, `Ending` headings.
- `.pro` import already implemented (`POST /api/songs/import-pro`).
---
# Alternative: Headless-browser scraping (NO official API)
Use this when the Partner API is not available (current default for new projects). It drives `songselect.ccli.com` with a real browser session using a normal CCLI SongSelect subscription. Same data the user would download manually, just automated.
## ToS / legal note
CCLI's SongSelect ToS forbids "automated retrieval" without partner agreement. A church-internal tool that only acts on behalf of an authenticated subscriber and respects rate limits is a gray area many open-source projects (OpenLP, FreeShow community fork, `gwonamfromkoradai/SongSelectSave`) operate in. Document the risk in `README` and let the church decide.
## Required credentials
```
CCLI_SONGSELECT_USER= # CCLI account email
CCLI_SONGSELECT_PASSWORD= # CCLI account password
CCLI_SONGSELECT_BASE_URL=https://songselect.ccli.com
```
Single shared app account (chosen). Encrypt the password at rest (`Crypt::encryptString`) — never log it.
## Tech stack pick
Three viable headless-browser options for Laravel:
| Tool | Pros | Cons |
|---|---|---|
| **`spatie/browsershot`** (Puppeteer + Chromium via Node) | Already in Laravel ecosystem; simple PHP API; supports cookies, headers, screenshots | Heavyweight; needs Node + Chromium in container |
| **`laravel/dusk`** (ChromeDriver) | Pure Laravel; auth helpers; assertion DSL | Built for testing, awkward for prod scraping |
| **Playwright via Node side-script** (`tests/e2e` already uses it) | Best automation API; persistent storage state; identical to existing E2E setup | Crosses PHP↔Node boundary (CLI exec or queue worker) |
**Recommendation: Playwright** — already a dev dep, `tests/e2e/auth.setup.ts` proves the pattern. Run as a queue job that shells out to a Node script, returns JSON.
DDEV needs Chromium installed — add to `.ddev/web-build/Dockerfile.example`:
```dockerfile
RUN apt-get update && apt-get install -y chromium fonts-liberation
RUN npx --yes playwright install --with-deps chromium
```
## Endpoints / DOM contract (observed)
These are not an "API" — they are URL + selector contracts that can change. Re-verify quarterly.
### 1. Login
- URL: `https://profile.ccli.com/account/signin?appContext=SongSelect`
- Form fields: `input[name="EmailAddress"]`, `input[name="Password"]`, `button[type="submit"]`
- Success: redirect to `https://songselect.ccli.com/`
- Persist cookies (`profile.ccli.com`, `songselect.ccli.com`) in `storage/app/ccli/state.json` (Playwright `storageState`). Re-login when cookies expire.
### 2. Search by keyword
- URL: `https://songselect.ccli.com/search/results?Keyword={url-encoded-query}`
- Result rows: `.song-result` (or current class — verify with DevTools)
- Fields per row: `.song-title a` (link + title), `.song-authors` (authors), `.song-ccli-number` or attribute `data-id` (CCLI #)
- Pagination: `?Keyword=...&CurrentPage=2`
### 3. Search by CCLI number
- URL: `https://songselect.ccli.com/Songs/{ccliId}` → redirects to canonical song page
### 4. Song detail
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}`
- Metadata in `<dl>` or schema.org JSON-LD `<script type="application/ld+json">` (preferred — stable):
- `name` → title
- `author[].name` → authors
- `copyrightYear`, `copyrightHolder`
- Themes / publishers in side panel.
### 5. Lyrics download (the "parts" the user wants)
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}/viewlyrics`
- Trigger: click `#lyricsDownloadButton` (gives `.txt`) OR fetch hidden link `a[data-download-format="txt"]`
- The `.txt` payload is **structured by part**, e.g.:
```
Verse 1
Amazing grace, how sweet the sound
...
Chorus
My chains are gone...
Verse 2
...
Bridge
...
CCLI Song # 22025
© Public Domain
CCLI License # 12345
```
- Headers to detect (regex): `^(Verse \d+|Chorus( \d+)?|Pre-Chorus|Bridge( \d+)?|Tag|Ending|Intro|Interlude|Refrain|Coda)\s*$`
### 6. ChordPro download (optional, if account has chord access)
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}/chordpro` → click `.chordpro-download`
- Format is industry-standard ChordPro — easier to parse than HTML.
## Mapping to existing schema
```
SongSelect part header → global Label name
─────────────────────────────────────────────
Verse N → Strophe N
Chorus / Refrain → Refrain
Pre-Chorus → Pre-Refrain
Bridge → Bridge
Tag / Ending / Coda → Outro
Intro / Interlude → Intro / Zwischenspiel
```
Lookup labels case-insensitive (`SongService::createDefaultGroups` already does `LOWER(name)`); create new global label if no match.
Persistence template (mirror `ProImportService::upsertSong`):
1. `Song::firstOrNew(['ccli_id' => $ccliId])` — restore soft-deleted via `restore()`
2. Update title / author / copyright_text / copyright_year / publisher
3. Wipe existing arrangements for clean re-import (or skip if user opted "merge")
4. Create one `SongArrangement(name='Normal', is_default=true)`
5. For each parsed part → find/create `Label`, create `SongSlide(label_id, order, text_content)`, attach via `SongArrangementLabel(order)`
## Service skeleton
```php
// app/Services/SongSelectScraperService.php
final class SongSelectScraperService
{
public function __construct(
private readonly SongImportService $importer,
) {}
public function search(string $query): Collection { /* runs node script: search */ }
public function fetchByCcliId(int $ccliId): array { /* runs node script: detail+lyrics */ }
public function importToDb(int $ccliId): Song
{
$payload = $this->fetchByCcliId($ccliId);
return $this->importer->upsertFromSongSelect($payload); // mirrors ProImportService
}
}
```
Run scraper inside a queue job (`ScrapeSongSelectJob`) — never block HTTP request. Frontend polls or uses Inertia partial reload.
## Node side-script (Playwright)
`scripts/songselect-fetch.mjs`:
```js
import { chromium } from 'playwright';
import fs from 'node:fs';
const [, , action, arg] = process.argv; // e.g. 'search' 'amazing grace' OR 'detail' 22025
const STATE = 'storage/app/ccli/state.json';
const browser = await chromium.launch({ headless: true });
const ctx = fs.existsSync(STATE)
? await browser.newContext({ storageState: STATE })
: await browser.newContext();
const page = await ctx.newPage();
// auto-login if cookies missing
await page.goto('https://songselect.ccli.com/');
if (await page.locator('text=Sign In').isVisible().catch(() => false)) {
await page.goto('https://profile.ccli.com/account/signin?appContext=SongSelect');
await page.fill('input[name="EmailAddress"]', process.env.CCLI_SONGSELECT_USER);
await page.fill('input[name="Password"]', process.env.CCLI_SONGSELECT_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('**/songselect.ccli.com/**');
await ctx.storageState({ path: STATE });
}
let result;
if (action === 'search') {
await page.goto(`https://songselect.ccli.com/search/results?Keyword=${encodeURIComponent(arg)}`);
result = await page.$$eval('.song-result', rows => rows.map(r => ({
ccli_id: r.dataset.id ?? r.querySelector('.song-ccli-number')?.textContent?.trim(),
title: r.querySelector('.song-title')?.textContent?.trim(),
authors: r.querySelector('.song-authors')?.textContent?.trim(),
url: r.querySelector('a')?.href,
})));
} else if (action === 'detail') {
await page.goto(`https://songselect.ccli.com/Songs/${arg}`);
const url = page.url();
const meta = await page.$eval('script[type="application/ld+json"]', s => JSON.parse(s.textContent));
await page.goto(url.replace(/\/?$/, '/viewlyrics'));
const lyrics = await page.locator('pre, .lyrics-content').innerText();
result = { ccli_id: arg, ...meta, lyrics };
}
console.log(JSON.stringify(result));
await browser.close();
```
PHP side calls via `Symfony\Component\Process\Process` and decodes JSON.
## Lyrics → parts parser (PHP)
```php
final class SongSelectLyricsParser
{
private const HEADER = '/^(Verse \d+|Chorus(?: \d+)?|Pre-Chorus|Bridge(?: \d+)?|Tag|Ending|Intro|Interlude|Refrain|Coda)\s*$/i';
private const LABEL_MAP = [
'verse' => 'Strophe', // suffix the number
'chorus' => 'Refrain',
'refrain' => 'Refrain',
'pre-chorus' => 'Pre-Refrain',
'bridge' => 'Bridge',
'tag' => 'Outro',
'ending' => 'Outro',
'coda' => 'Outro',
'intro' => 'Intro',
'interlude' => 'Zwischenspiel',
];
/** @return array<int, array{label: string, text: string}> */
public function parse(string $raw): array { /* split on HEADER, map via LABEL_MAP */ }
}
```
## Rate limiting & politeness
- Cap to **30 requests/minute** per app instance (`RateLimiter::for('ccli-scrape', fn () => Limit::perMinute(30))`).
- One concurrent scrape job (`ScrapeSongSelectJob` with `WithoutOverlapping` middleware).
- Cache result for 30 days (`songs.ccli_id` already keyed). User can force-refresh via "Re-import" button.
- Random jitter 500-1500ms between page loads.
## UI integration
1. **`Songs/Index.vue`** — top-bar search input "CCLI Lookup" → `POST /api/ccli/search { q }` → modal with results → "Import" button per row.
2. **`SongAgendaItem.vue`** (unmatched row) — new button "SongSelect suchen" next to existing Request/Assign → opens same modal pre-filled with CTS song name.
3. **Preview modal before save** — show parsed parts grouped by detected Label, allow drag-reassign / rename, then confirm import.
4. All German text, Du-form: "Suche bei CCLI…", "Importieren", "Als Strophe 1 zuweisen", etc.
## Failure modes & detection
| Symptom | Cause | Action |
|---|---|---|
| Redirect to `/account/signin` mid-session | Cookie expired | Re-run login flow, retry once |
| Empty `.song-result` list | DOM changed OR query 0 hits | Save HTML snapshot to `storage/logs/ccli/` for inspection |
| HTTP 429 / "Too many requests" page | Rate limit hit | Back off 5min, alert admin |
| Captcha (`recaptcha` iframe) | CCLI flagged automation | Stop, surface admin notice, fall back to manual paste |
| Login fails | Wrong creds OR account suspended | German error to admin |
Log every scrape into `api_request_logs` (existing table) with `service='songselect'` so the existing log UI shows them alongside CTS calls.
## Testing
- Unit-test the parser with fixtures in `tests/Fixtures/songselect/*.txt`.
- Mock the Playwright invocation in service tests via constructor closure (mirror `ChurchToolsService` pattern).
- E2E test against a sandbox public-domain song (e.g. CCLI #22025 "Amazing Grace") — gated by `CCLI_SONGSELECT_USER` env, skip if missing.
## Bootstrap checklist for a new agent
1. Confirm CCLI subscription credentials are in `.env`.
2. Add Chromium to DDEV web container.
3. Create `scripts/songselect-fetch.mjs`.
4. Create `app/Services/SongSelectScraperService.php` + `SongSelectLyricsParser.php` + `SongImportService::upsertFromSongSelect()` (refactor common parts out of `ProImportService`).
5. Create `ScrapeSongSelectJob` (queued, `WithoutOverlapping`).
6. Add routes `POST /api/ccli/search`, `POST /api/ccli/import/{ccliId}`.
7. Add Vue search modal + integrate into `Songs/Index.vue` + `SongAgendaItem.vue`.
8. Write parser unit tests + service feature test (mock Process).
9. Document the ToS gray area in README.
---
# Reference: How OpenLP imports from CCLI
Source: `openlp/plugins/songs/lib/songselect.py` on https://gitlab.com/openlp/openlp (LGPL).
**Approach: embedded Qt WebEngine (= real Chromium) + JS injection**
OpenLP does NOT do headless HTTP scraping. It opens a `QWebEngineView` (PySide6 Qt Chromium) inside the desktop app on `https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https%3a%2f%2fsongselect.ccli.com%2f`. The user signs in **manually** in that embedded browser (so they solve any captcha themselves). After login the same webview holds the authenticated cookies.
OpenLP then drives the page via `webview.page().runJavaScript(...)` to:
1. Detect current page by URL (`Login` / `Home` / `Search` / `Song` / `Other`).
2. Navigate by setting `document.location = "<url>"`.
3. Pre-fill login fields:
```js
document.getElementById("EmailAddress").value = "<email>";
document.getElementById("Password").value = "<password>";
```
(User still clicks Sign-In manually so Turnstile sees a real interaction.)
4. **Fetch any URL with the page's session cookies** by injecting:
```js
var openlp_page_data = null;
fetch("<url>")
.then(r => r.text())
.then(t => { openlp_page_data = t; });
```
then polls `openlp_page_data != null` and reads the result back into Python. This is the clever bit — they bypass cookie-export entirely, using the already-authenticated browser context as the HTTP client.
5. Parse HTML → song dict → write into the OpenLP DB via SQLAlchemy (`Song`, `Author`, `Topic`, `SongXML` verses with `VerseType.tags`).
URL constants in OpenLP:
```python
BASE_URL = 'https://songselect.ccli.com'
LOGIN_PAGE = 'https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https%3a%2f%2fsongselect.ccli.com%2f'
LOGIN_URL = 'https://profile.ccli.com'
LOGOUT_URL = BASE_URL + '/account/logout'
SEARCH_URL = BASE_URL + '/search/results'
SONG_PAGE = BASE_URL + '/Songs/'
CCLI_NUMBER_REGEX = r'.*?Songs\/([0-9]+).*'
```
**Lesson for a Laravel server-side port**: OpenLP succeeds because it ships a full GUI Chromium and pushes the captcha problem onto the user. A server-side scraper has to solve the same captcha non-interactively — see next section.
# Cloudflare Turnstile on CCLI login (verified 2026-05)
Confirmed by fetching `https://profile.ccli.com/account/signin?appContext=SongSelect`:
```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
<div class="cf-turnstile sr-only"
data-sitekey="0x4AAAAAAA1USwfe0YamenZA"
data-appearance="interaction-only"
data-callback="enableSubmit" inert></div>
```
- **Mode**: `interaction-only` (Managed/Invisible — silent unless trust score drops, then escalates to checkbox click)
- **Sitekey**: `0x4AAAAAAA1USwfe0YamenZA`
- **Submit button is disabled until Turnstile callback fires**, then a hidden `cf-turnstile-response` input is added to the POST body
- Form also includes ASP.NET `__RequestVerificationToken` (CSRF) — must be scraped from the GET response and sent back
- CCLI also injects **Cloudflare Bot Management JSD** (`/cdn-cgi/challenge-platform/scripts/jsd/main.js`) — additional passive fingerprinting on every page
## Can Turnstile be bypassed WITHOUT a real Chrome?
**Short answer: No.** Turnstile requires a JavaScript runtime + canvas + WebGL + AudioContext + matching TLS/JA3 fingerprint to mint a valid token. A real browser engine must run somewhere — locally, in a queue worker, or in the cloud.
The realistic option matrix:
| Approach | "Real Chrome" needed? | Cost | Reliability for CCLI | Notes |
|---|---|---|---|---|
| **Pure HTTP** (Guzzle / curl / requests) | none | free | **Will not work** | Cannot execute the Turnstile JS that mints the token. Hard wall. |
| **`curl-impersonate` / `curl_cffi`** (TLS-fingerprint spoofing) | none | free | **Will not work alone** | Solves JA3 fingerprint but still no JS engine for the Turnstile widget. Useful only AFTER a session cookie exists. |
| **Patched headless Chromium** (Playwright + `playwright-stealth`, `puppeteer-extra-plugin-stealth`, `nodriver`, `patchright`) | yes (local) | free | **Medium** for `interaction-only` mode | Stealth plugins hide `navigator.webdriver`, fix canvas/WebGL leaks. Often passes Turnstile silently. Breaks under residential-IP requirement or escalation to interactive. |
| **`undetected-chromedriver` + SeleniumBase UC Mode** | yes (local) | free | **Medium-High** | Has built-in `uc_gui_click_captcha()` that uses pyautogui to click the checkbox if Turnstile escalates. Python-only. |
| **Camoufox** (patched Firefox, fingerprint injection at C++ level) | yes (local) | free | **Medium-High** | Different signature from Chromium-based detection profiles; useful when stealth-Chromium gets flagged. |
| **CAPTCHA-solving service** (2Captcha, CapSolver, NextCaptcha, Anti-Captcha) | none locally; service runs browsers | ≈$1.45/1k tokens | **Low for CCLI specifically** | They return a Turnstile token bound to the sitekey + your IP. CCLI also fingerprints the browser env + JSD beacon, so token alone often fails to authenticate. Token TTL ≈ 5min, single-use. |
| **Cloud browser API** (Scrapfly ASP, Browserless, Bright Data Scraping Browser, Scrapeless, ZenRows, Oxylabs Web Unblocker) | yes (remote) | ≈$5-50/1k pages | **High** | Real Chromium + residential proxy + automatic challenge solving in one call. The only "no local Chrome" option that actually works at scale. |
| **Manual one-time login + persisted cookies** (OpenLP model) | yes (one-time, in user's own browser) | free | **High** | User logs in once via popup/embedded view, app stores `.AspNet.ApplicationCookie` + Cloudflare `cf_clearance` cookies, reuses them for HTTP scraping until they expire (typically 30 days; `cf_clearance` is shorter ≈ 1 hour but auto-refreshes if you keep the same browser fingerprint via `curl-impersonate`). |
**`cf_clearance` cookie pitfall**: even with a valid `.AspNet.ApplicationCookie`, Cloudflare checks `cf_clearance` on every request and ties it to the originating browser's TLS+UA fingerprint. Reusing the cookie from raw `curl` will give `403 / cf_chl_*` because the JA3 fingerprint won't match. Use `curl-impersonate-chrome` or `curl_cffi` (`curl_cffi.requests` with `impersonate="chrome120"`) so the TLS handshake matches the browser that minted the cookie.
## Recommended architecture for pp-planer
Hybrid that mirrors OpenLP's user-driven login but server-side scraping:
1. **Admin panel "CCLI Session" page**
- "Sign in to CCLI" button opens a popup window pointed at `https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https://pp-planer.ddev.site/api/ccli/oauth-callback`.
- User logs in normally. Their own browser handles Turnstile (silent in 99% of cases for residential IPs).
- On the redirect back to our callback, JS reads `document.cookie` from the popup (only works for cookies on **our** domain — see below) — so this approach actually requires a different mechanism.
2. **Better: bundled headless browser inside a queue worker**
- Use Playwright (already a dev dep) + `playwright-extra` + `playwright-extra-plugin-stealth` in headed mode for first login, headless for re-use.
- Persist `storageState` to `storage/app/ccli/state.json` (encrypted at rest).
- First-time setup: admin runs `php artisan ccli:login` → opens a non-headless Playwright browser on the server's display (or via VNC/X11 forwarding in DDEV) → admin types credentials and solves any escalated Turnstile checkbox.
- All subsequent fetches use saved cookies in headless mode. Re-prompt admin when cookies expire.
3. **For ongoing fetches**: once authenticated, can drop down to `curl_cffi`-style HTTP via Symfony HttpClient with a Chrome JA3 fingerprint (PHP package: `quic-go/curl-impersonate` shell-out, or call Node `curl-impersonate` script) — much faster than re-launching browser per request.
4. **Fallback if Turnstile escalates beyond stealth limits**: route through a cloud browser (Scrapfly ASP `asp=true` flag handles it). Make it pluggable behind `SongSelectClient` interface.
## Honest recommendation
For a church-internal tool used by a handful of staff, scraping at all is overkill. Realistic ranking:
1. **Manual paste flow** + lyric parser → 2 days of work, zero external deps, zero ToS risk.
2. **`.pro` import** (already done) — staff can download `.pro` files from SongSelect manually and drop them in the existing upload area.
3. **OpenLP-style embedded webview** — only works for desktop; doesn't fit a Laravel web app.
4. **Server-side stealth Playwright + persisted cookies** — works, but ~1-2 weeks of fragile glue code, breaks every CCLI redesign or Cloudflare ruleset bump.
5. **Cloud browser API (Scrapfly etc.)** — most reliable, costs €€, still ToS-gray.
If automation is mandatory: option 4 with option 5 as fallback when the local browser fails.

View file

@ -1,15 +0,0 @@
<?php
namespace App\Exceptions;
use RuntimeException;
final class DuplicateCcliSongException extends RuntimeException
{
public function __construct(
public readonly int $existingSongId,
string $message = '',
) {
parent::__construct($message ?: "Song mit dieser CCLI-Nummer existiert bereits (#{$existingSongId})");
}
}

View file

@ -1,47 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Config;
final class BookmarkletController extends Controller
{
public function show(): Response
{
$appUrl = rtrim((string) Config::get('app.url', ''), '/');
$bookmarkletScript = <<<'BOOKMARKLET'
(function(){
var APP_URL = '__APP_URL__';
if(!location.hostname.includes('songselect.ccli.com')){
alert('Bitte öffne dieses Lesezeichen auf einer SongSelect Liedseite (songselect.ccli.com).');
return;
}
var title = (document.querySelector('h1, .song-title, [class*="title"]') || {}).innerText || document.title || '';
var author = (document.querySelector('.song-authors, .song-artist, [class*="author"]') || {}).innerText || '';
var bodyText = document.body ? document.body.innerText : '';
var ccliMatch = bodyText.match(/CCLI[\s#-]*(\d+)/i);
var ccliId = ccliMatch ? ccliMatch[1] : '';
var payload = {
title: title.trim(),
author: author.trim(),
ccliId: ccliId,
sourceUrl: location.href,
rawText: bodyText
};
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank');
})();
BOOKMARKLET;
$bookmarkletScript = str_replace('__APP_URL__', $appUrl, $bookmarkletScript);
$singleLine = 'javascript:'.preg_replace('/\s+/', ' ', $bookmarkletScript);
return response($singleLine, 200, [
'Content-Type' => 'text/javascript; charset=utf-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}

View file

@ -1,177 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\DuplicateCcliSongException;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\CcliImportService;
use App\Services\CcliPasteParser;
use App\Services\CcliTranslationPairingService;
use App\Services\SongMatchingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class CcliPasteController extends Controller
{
public function __construct(
private readonly CcliPasteParser $parser,
private readonly CcliImportService $importService,
private readonly CcliTranslationPairingService $pairingService,
private readonly SongMatchingService $matchingService,
) {}
/**
* POST /api/ccli/preview
* Parse raw text and return DTO as JSON. No DB writes.
*/
public function preview(Request $request): JsonResponse
{
$validated = $request->validate([
'raw_text' => ['required', 'string', 'max:200000'],
]);
try {
$parsed = $this->parser->parse($validated['raw_text']);
} catch (InvalidArgumentException $e) {
return response()->json([
'message' => $e->getMessage(),
], 422);
}
return response()->json([
'title' => $parsed->title,
'author' => $parsed->author,
'ccliId' => $parsed->ccliId,
'year' => $parsed->year,
'copyrightText' => $parsed->copyrightText,
'sections' => array_map(fn ($s) => [
'label' => $s->label,
'kind' => $s->kind,
'number' => $s->number,
'modifier' => $s->modifier,
'lines' => $s->lines,
'hasTranslation' => $s->linesTranslated !== null,
], $parsed->sections),
]);
}
/**
* POST /api/songs/import-from-ccli-paste
* Import a CCLI paste in one of 3 modes.
*/
public function importPaste(Request $request): JsonResponse
{
$validated = $request->validate([
'raw_text' => ['required', 'string', 'max:200000'],
'mode' => ['required', Rule::in(['create', 'pair-with-song', 'assign-to-service-song'])],
'target_id' => ['required_unless:mode,create', 'nullable', 'integer'],
'source_url' => ['nullable', 'string', 'max:500'],
]);
$rawText = $validated['raw_text'];
$mode = $validated['mode'];
$targetId = $validated['target_id'] ?? null;
$sourceUrl = $validated['source_url'] ?? null;
try {
if ($mode === 'create') {
$result = $this->importService->import($rawText, $sourceUrl);
return response()->json([
'song_id' => $result['song']->id,
'status' => $result['status'],
'warnings' => $result['warnings'],
], 201);
}
if ($mode === 'pair-with-song') {
$localSong = Song::findOrFail($targetId);
$result = $this->pairingService->pair($localSong, $rawText);
session()->flash('ccli_prefilled', $result['distributed_text']);
return response()->json([
'song_id' => $localSong->id,
'mapping' => $result['mapping'],
'unmatched_labels' => $result['unmatched_labels'],
'redirect_to' => route('songs.translate', $localSong->id).'?prefilled=true',
]);
}
if ($mode === 'assign-to-service-song') {
$result = $this->importService->import($rawText, $sourceUrl);
$song = $result['song'];
$serviceSong = ServiceSong::findOrFail($targetId);
$this->matchingService->manualAssign($serviceSong, $song);
return response()->json([
'song_id' => $song->id,
'service_song_id' => $serviceSong->id,
'status' => $result['status'],
], 201);
}
} catch (DuplicateCcliSongException $e) {
return response()->json([
'message' => $e->getMessage(),
'existing_song_id' => $e->existingSongId,
'edit_url' => route('songs.index').'#song-'.$e->existingSongId,
], 409);
} catch (InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['message' => 'Unbekannter Modus'], 422);
}
/**
* GET /songs/import-from-ccli-paste
* Render the Inertia page for bookmarklet redirect.
*/
public function showImportPage(Request $request): InertiaResponse
{
$prefill = $request->query('prefill');
$prefilledText = null;
$prefilledMetadata = null;
$prefillError = null;
if ($prefill !== null && is_string($prefill)) {
try {
$decoded = base64_decode($prefill, strict: true);
if ($decoded === false) {
throw new InvalidArgumentException('Ungültige Kodierung');
}
$payload = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR);
if (! is_array($payload)) {
throw new InvalidArgumentException('Ungültiges Payload-Format');
}
$prefilledText = $payload['rawText'] ?? null;
$prefilledMetadata = [
'title' => $payload['title'] ?? null,
'author' => $payload['author'] ?? null,
'ccliId' => $payload['ccliId'] ?? null,
'sourceUrl' => $payload['sourceUrl'] ?? null,
];
} catch (Throwable) {
$prefillError = 'Lesezeichen-Daten konnten nicht gelesen werden. Bitte den Liedtext manuell einfügen.';
}
}
return Inertia::render('Songs/ImportFromCcliPaste', [
'prefilledText' => $prefilledText,
'prefilledMetadata' => $prefilledMetadata,
'prefillError' => $prefillError,
]);
}
}

View file

@ -9,7 +9,6 @@
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@ -20,7 +19,6 @@ class SettingsController extends Controller
'agenda_end_title',
'agenda_announcement_position',
'agenda_sermon_matching',
'default_translation_language',
];
public function index(): Response
@ -50,16 +48,10 @@ public function index(): Response
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'key' => ['required', 'string', Rule::in(self::AGENDA_KEYS)],
'key' => ['required', 'string', 'in:'.implode(',', self::AGENDA_KEYS)],
'value' => ['nullable', 'string', 'max:500'],
]);
if ($validated['key'] === 'default_translation_language') {
validator($validated, [
'value' => ['nullable', Rule::in(['DE', 'EN', 'FR', 'ES', 'NL', 'IT'])],
])->validate();
}
Setting::set($validated['key'], $validated['value']);
return response()->json(['success' => true]);

View file

@ -57,7 +57,6 @@ public function page(Song $song): Response
'has_translation' => $song->has_translation,
'groups' => $groups,
],
'prefilledTranslation' => session()->pull('ccli_prefilled'),
]);
}

View file

@ -16,8 +16,6 @@ class Song extends Model
protected $fillable = [
'ccli_id',
'cts_song_id',
'imported_from_ccli_at',
'ccli_source_url',
'title',
'author',
'copyright_text',
@ -32,7 +30,6 @@ protected function casts(): array
return [
'has_translation' => 'boolean',
'last_used_at' => 'datetime',
'imported_from_ccli_at' => 'datetime',
];
}

View file

@ -1,151 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\DuplicateCcliSongException;
use App\Models\ApiRequestLog;
use App\Models\Label;
use App\Models\Setting;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong;
use App\Support\CcliLabels;
use Illuminate\Support\Facades\DB;
use RuntimeException;
final class CcliImportService
{
private const DEFAULT_LABEL_COLOR = '#3B82F6';
public function __construct(
private readonly CcliPasteParser $parser,
) {}
/** @return array{song: Song, status: 'created'|'restored', warnings: string[]} */
public function import(string $rawText, ?string $sourceUrl = null): array
{
$startedAt = microtime(true);
$parsed = $this->parser->parse($rawText);
if ($parsed->ccliId === null || trim($parsed->ccliId) === '') {
throw new RuntimeException('Keine CCLI-Nummer gefunden — bitte vollständige SongSelect-Liedseite einfügen.');
}
$song = Song::withTrashed()->where('ccli_id', $parsed->ccliId)->first();
$status = 'created';
if ($song !== null && ! $song->trashed()) {
throw new DuplicateCcliSongException($song->id);
}
if ($song !== null) {
$status = 'restored';
}
return DB::transaction(function () use ($parsed, $sourceUrl, $song, $status, $startedAt): array {
if ($song !== null && $song->trashed()) {
$song->restore();
}
$song = $this->upsertSong($parsed, $sourceUrl, $song);
$warnings = [];
$translationLanguage = Setting::get('default_translation_language', 'DE');
if ($translationLanguage === null || trim($translationLanguage) === '') {
$warnings[] = 'Keine Standard-Übersetzungssprache gesetzt, DE wird verwendet.';
}
$labelIds = [];
$hasTranslation = false;
foreach ($parsed->sections as $section) {
$label = $this->resolveLabel($section);
$labelIds[] = $label->id;
$label->songSlides()->delete();
foreach ($section->lines as $order => $line) {
$translatedLine = $section->linesTranslated[$order] ?? null;
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
SongSlide::create([
'label_id' => $label->id,
'order' => $order + 1,
'text_content' => $line,
'text_content_translated' => $translatedLine,
]);
}
}
$song->update([
'has_translation' => $hasTranslation,
'imported_from_ccli_at' => now(),
'ccli_source_url' => $sourceUrl ?? $parsed->sourceUrl,
]);
$arrangement = SongArrangement::updateOrCreate(
['song_id' => $song->id, 'name' => 'normal'],
['is_default' => true],
);
SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->delete();
foreach ($labelIds as $order => $labelId) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelId,
'order' => $order + 1,
]);
}
$song = $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
ApiRequestLog::create([
'method' => 'import',
'endpoint' => 'paste',
'status' => 'success',
'request_context' => ['ccli_id' => $parsed->ccliId, 'mode' => $status],
'response_summary' => "Song {$status}: {$song->title}",
'response_body' => null,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
]);
return ['song' => $song, 'status' => $status, 'warnings' => $warnings];
});
}
private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $song): Song
{
$songData = [
'title' => $parsed->title,
'author' => $parsed->author,
'copyright_text' => $parsed->copyrightText,
'copyright_year' => $parsed->year,
'publisher' => $parsed->copyrightText,
'ccli_source_url' => $sourceUrl ?? $parsed->sourceUrl,
];
if ($song !== null) {
$song->update($songData);
return $song;
}
return Song::create(array_merge($songData, ['ccli_id' => $parsed->ccliId]));
}
private function resolveLabel(ParsedCcliSection $section): Label
{
$canonicalLabelName = CcliLabels::normalizeLabelName(
$section->kind.($section->number ? ' '.$section->number : ''),
);
return Label::firstOrCreate(
['name' => $canonicalLabelName],
['color' => self::DEFAULT_LABEL_COLOR, 'last_imported_at' => now()],
);
}
}

View file

@ -1,180 +0,0 @@
<?php
namespace App\Services;
use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong;
use App\Support\CcliLabels;
use Closure;
use InvalidArgumentException;
final class CcliPasteParser
{
public function __construct(
private readonly ?Closure $sectionDetector = null,
private readonly ?Closure $metadataDetector = null,
) {}
public function parse(string $rawText): ParsedCcliSong
{
if (trim($rawText) === '') {
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
}
$lines = array_map(
fn (string $line): string => trim($line),
preg_split('/\r\n|\n|\r/', $rawText) ?: [],
);
$isSectionLabel = $this->sectionDetector ?? fn (string $line): bool => CcliLabels::isSectionLabel($line);
$isMetadataLine = $this->metadataDetector ?? fn (string $line): bool => CcliLabels::isMetadataLine($line);
$firstSectionIndex = null;
foreach ($lines as $index => $line) {
if ($line !== '' && $isSectionLabel($line)) {
$firstSectionIndex = $index;
break;
}
}
if ($firstSectionIndex === null) {
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
}
$headerLines = array_values(array_filter(
array_slice($lines, 0, $firstSectionIndex),
fn (string $line): bool => $line !== '',
));
$title = $headerLines[0] ?? '';
$author = $headerLines[1] ?? null;
$ccliId = null;
$year = null;
$copyrightText = null;
$sections = [];
$current = null;
foreach (array_slice($lines, $firstSectionIndex) as $line) {
if ($line === '') {
continue;
}
if ($isMetadataLine($line)) {
$extractedCcliId = CcliLabels::extractCcliId($line);
if ($extractedCcliId !== null) {
$ccliId = $extractedCcliId;
}
if (str_contains($line, '©')) {
$copyrightText = $line;
if (preg_match('/©\s*(\d{4})/u', $line, $matches)) {
$year = $matches[1];
}
}
continue;
}
if ($isSectionLabel($line)) {
if ($current !== null) {
$sections[] = $current;
}
$label = CcliLabels::parseLabel($line);
if ($label === null) {
continue;
}
$current = [
'label' => $line,
'kind' => CcliLabels::normalizeLabelName($label['kind']),
'rawKind' => $label['kind'],
'number' => $label['number'],
'modifier' => $label['modifier'],
'lines' => [],
];
continue;
}
if ($current !== null) {
$current['lines'][] = $line;
}
}
if ($current !== null) {
$sections[] = $current;
}
$parsedSections = $this->mergeTranslatedSections($sections);
if ($parsedSections === []) {
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
}
return new ParsedCcliSong(
title: $title,
author: $author,
ccliId: $ccliId,
year: $year,
copyrightText: $copyrightText,
sourceUrl: null,
sections: $parsedSections,
);
}
/**
* @param array<int, array{label: string, kind: string, rawKind: string, number: string|null, modifier: string|null, lines: string[]}> $sections
* @return ParsedCcliSection[]
*/
private function mergeTranslatedSections(array $sections): array
{
$merged = [];
$index = 0;
while ($index < count($sections)) {
$section = $sections[$index];
$next = $sections[$index + 1] ?? null;
$linesTranslated = null;
if ($next !== null && $this->isTranslatedPair($section, $next)) {
$linesTranslated = $next['lines'];
$index++;
}
$merged[] = new ParsedCcliSection(
label: $section['label'],
kind: $section['kind'],
number: $section['number'],
modifier: $section['modifier'],
lines: $section['lines'],
linesTranslated: $linesTranslated,
);
$index++;
}
return $merged;
}
/**
* @param array{kind: string, rawKind: string, number: string|null} $section
* @param array{kind: string, rawKind: string, number: string|null} $next
*/
private function isTranslatedPair(array $section, array $next): bool
{
return mb_strtolower($section['rawKind']) !== mb_strtolower($next['rawKind'])
&& $this->canonicalLabel($section) === $this->canonicalLabel($next);
}
/**
* @param array{kind: string, number: string|null} $section
*/
private function canonicalLabel(array $section): string
{
$label = trim($section['kind'].' '.($section['number'] ?? ''));
return mb_strtolower(CcliLabels::normalizeLabelName($label));
}
}

View file

@ -1,148 +0,0 @@
<?php
namespace App\Services;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Services\DTO\ParsedCcliSection;
use App\Support\CcliLabels;
use Illuminate\Support\Collection;
final class CcliTranslationPairingService
{
public function __construct(
private readonly CcliPasteParser $parser,
) {}
/**
* Pair a CCLI paste as translation for an existing local song.
* Returns mapping for UI review (does NOT save to DB).
*
* @return array{
* song: Song,
* mapping: array<int, array{local_label: string, ccli_label: string|null, distributed_lines: string[]}>,
* unmatched_labels: string[],
* distributed_text: string
* }
*/
public function pair(Song $localSong, string $ccliRawText, string $arrangementName = 'normal'): array
{
$parsed = $this->parser->parse($ccliRawText);
$localSong->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
$arrangement = $this->findArrangement($localSong, $arrangementName);
if ($arrangement === null) {
return [
'song' => $localSong,
'mapping' => [],
'unmatched_labels' => [],
'distributed_text' => '',
];
}
$ccliByCanonical = $this->sectionsByCanonicalLabel($parsed->sections);
$mapping = [];
$unmatchedLabels = [];
$allDistributedLines = [];
foreach ($arrangement->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
continue;
}
$localCanonical = $this->canonicalLabel($label->name, null);
$matchedSection = $ccliByCanonical[$localCanonical] ?? null;
$slides = $label->songSlides->sortBy('order')->values();
if ($matchedSection === null) {
$unmatchedLabels[] = $label->name;
$distributedLines = array_fill(0, max($slides->count(), 1), '');
} else {
$distributedLines = $this->distributeLines(
$matchedSection->linesTranslated ?? $matchedSection->lines,
$slides,
);
}
$allDistributedLines = array_merge($allDistributedLines, $distributedLines);
$mapping[] = [
'local_label' => $label->name,
'ccli_label' => $matchedSection?->label,
'distributed_lines' => $distributedLines,
];
}
return [
'song' => $localSong,
'mapping' => $mapping,
'unmatched_labels' => $unmatchedLabels,
'distributed_text' => implode("\n", $allDistributedLines),
];
}
private function findArrangement(Song $localSong, string $arrangementName): ?SongArrangement
{
return $localSong->arrangements->where('name', $arrangementName)->first()
?? $localSong->arrangements->where('is_default', true)->first()
?? $localSong->arrangements->first();
}
/**
* @param ParsedCcliSection[] $sections
* @return array<string, ParsedCcliSection>
*/
private function sectionsByCanonicalLabel(array $sections): array
{
$byCanonical = [];
foreach ($sections as $section) {
$canonical = $this->canonicalLabel($section->kind, $section->number);
$byCanonical[$canonical] ??= $section;
}
return $byCanonical;
}
private function canonicalLabel(string $kind, ?string $number): string
{
$label = trim($kind.' '.($number ?? ''));
return mb_strtolower(CcliLabels::normalizeLabelName($label));
}
/**
* Distribute CCLI lines into local slide slots, preserving each local slide line count.
*
* @param string[] $lines
* @param Collection<int, mixed> $slides
* @return string[]
*/
private function distributeLines(array $lines, Collection $slides): array
{
if ($slides->isEmpty()) {
return $lines;
}
$distributed = [];
$offset = 0;
$lastSlideIndex = $slides->count() - 1;
foreach ($slides as $index => $slide) {
$lineCount = max(count(explode("\n", $slide->text_content ?? '')), 1);
$chunk = array_slice($lines, $offset, $lineCount);
$offset += $lineCount;
if ($index === $lastSlideIndex && $offset < count($lines)) {
$chunk = array_merge($chunk, array_slice($lines, $offset));
}
$distributed[] = implode("\n", $chunk);
}
return $distributed;
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Services\DTO;
final readonly class ParsedCcliSection
{
public function __construct(
public string $label,
public string $kind,
public ?string $number,
public ?string $modifier,
/** @var string[] */
public array $lines,
/** @var string[]|null */
public ?array $linesTranslated = null,
) {}
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Services\DTO;
final readonly class ParsedCcliSong
{
public function __construct(
public string $title,
public ?string $author,
public ?string $ccliId,
public ?string $year,
public ?string $copyrightText,
public ?string $sourceUrl,
/** @var ParsedCcliSection[] */
public array $sections,
) {}
}

View file

@ -1,85 +0,0 @@
<?php
namespace App\Support;
final class CcliLabels
{
/**
* Regex matching CCLI SongSelect section labels (English + German + variants).
*/
public const SECTION_LABEL_PATTERN = '/^(Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu';
/**
* Regex matching CCLI footer/metadata lines (copyright, CCLI number).
*/
public const METADATA_PATTERN = '/©|CCLI[\s\-]|ccli\.com|SongSelect|All rights reserved|Alle Rechte vorbehalten/iu';
/**
* Bidirectional English German label kind mapping.
*/
public const LABEL_NAME_MAP = [
'Strophe' => 'Verse',
'Refrain' => 'Chorus',
'Brücke' => 'Bridge',
'Vorrefrain' => 'Pre-Chorus',
'Schluss' => 'Ending',
'Zwischenspiel' => 'Interlude',
];
public static function isSectionLabel(string $line): bool
{
return (bool) preg_match(self::SECTION_LABEL_PATTERN, trim($line));
}
public static function isMetadataLine(string $line): bool
{
return (bool) preg_match(self::METADATA_PATTERN, $line);
}
public static function extractCcliId(string $line): ?string
{
if (preg_match('/CCLI\s*(?:License|Lizenz)/iu', $line)) {
return null;
}
if (! preg_match('/CCLI(?:[\s-]*(?:Song|Lied(?:nummer)?|Nr\.?))?[\s#:\-.]*(\d+)/iu', $line, $matches)) {
return null;
}
return $matches[1];
}
public static function normalizeLabelName(string $label): string
{
$trimmed = trim($label);
if (! preg_match('/^(?<kind>Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?<suffix>\s+\d+[a-z]?)?$/iu', $trimmed, $matches)) {
return $trimmed;
}
$kind = $matches['kind'];
$suffix = $matches['suffix'] ?? '';
return (self::LABEL_NAME_MAP[$kind] ?? $kind).$suffix;
}
/**
* @return array{kind: string, number: string|null, modifier: string|null}|null
*/
public static function parseLabel(string $line): ?array
{
$trimmed = trim($line);
if (! preg_match('/^(?<kind>Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)(?:\s+(?<number>\d+[a-z]?))?(?:\s*\((?<modifier>Repeat|Wdh\.?)\))?(?:\s*[xX]\s*\d+)?$/iu', $trimmed, $matches)) {
return null;
}
$modifier = $matches['modifier'] ?? null;
return [
'kind' => $matches['kind'],
'number' => $matches['number'] ?? null,
'modifier' => $modifier !== null ? rtrim($modifier, '.') : null,
];
}
}

View file

@ -52,10 +52,6 @@ RUN composer run-script post-autoload-dump --no-interaction || true
RUN npm run build
# Copy built Vite assets to /app/public-build so they survive the bind-mount at runtime.
# At boot, boot-container.sh copies from /app/public-build/ into the bind-mounted /app/public/.
RUN cp -r /app/public /app/public-build
# =============================================================================
# Stage 2: Production
@ -78,6 +74,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unzip \
zip \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Node.js 20 LTS — needed at boot to build Vite assets into the bind-mounted public/
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# LibreOffice for PowerPoint → PDF conversion (large layer, separate cache)
@ -131,7 +133,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q "pong" || exit 1
# boot-container.sh runs as root: creates dirs, sets permissions,
# creates DB on first run, syncs pre-built Vite assets from /app/public-build/,
# runs migrations, warms caches, then exec's supervisord (CMD).
# creates DB on first run, builds Vite assets, runs migrations,
# warms caches, then exec's supervisord (CMD).
ENTRYPOINT ["/app/build/boot-container.sh"]
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View file

@ -26,8 +26,8 @@ chmod -R 775 storage bootstrap/cache database 2>/dev/null || true
rm -f /app/public/hot
echo "[boot] Syncing pre-built Vite assets to bind-mounted public/ ..."
cp -r /app/public-build/* /app/public/ 2>/dev/null || true
echo "[boot] Building Vite assets..."
npm run build
# Create RELATIVE storage symlink (public/storage → ../storage/app/public).
# Must be relative: Caddy serves the bind-mounted ./public from the host, where

View file

@ -13,7 +13,6 @@ fi
echo "[init] First run detected — initializing application..."
touch "$DB_PATH"
chown www-data:www-data "$DB_PATH"
chmod 664 "$DB_PATH"
if [ -z "${APP_KEY}" ]; then

View file

@ -7,7 +7,6 @@
'SANCTUM_STATEFUL_DOMAINS',
implode(',', array_filter([
$appHost,
'pp-planer.ddev.site',
'localhost',
'localhost:8000',
'127.0.0.1',

View file

@ -22,12 +22,4 @@ public function definition(): array
'last_used_at' => $this->faker->optional()->dateTimeBetween('-6 months', 'now'),
];
}
public function fromCcli(): self
{
return $this->state(fn (): array => [
'imported_from_ccli_at' => now(),
'ccli_source_url' => 'https://songselect.ccli.com/Songs/9999001',
]);
}
}

View file

@ -1,23 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('songs', function (Blueprint $table): void {
$table->timestamp('imported_from_ccli_at')->nullable()->after('last_used_at');
$table->string('ccli_source_url', 500)->nullable()->after('imported_from_ccli_at');
});
}
public function down(): void
{
Schema::table('songs', function (Blueprint $table): void {
$table->dropColumn(['imported_from_ccli_at', 'ccli_source_url']);
});
}
};

View file

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Setting;
use Illuminate\Database\Seeder;
class CcliSettingsSeeder extends Seeder
{
public function run(): void
{
Setting::firstOrCreate(
['key' => 'default_translation_language'],
['value' => 'DE'],
);
}
}

View file

@ -21,7 +21,5 @@ public function run(): void
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->call(CcliSettingsSeeder::class);
}
}

512
package-lock.json generated
View file

@ -520,9 +520,9 @@
}
},
"node_modules/@inertiajs/core": {
"version": "2.3.23",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.23.tgz",
"integrity": "sha512-d+jcdf91RY5bjT+ivBn2gu9Qsmxx5CacvVmVp7o9H2Lu/k1cBD0m4UZB5YKWfG11aBCucShBxuppEXme7sSEow==",
"version": "2.3.21",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.21.tgz",
"integrity": "sha512-grHSCUiWDBWqpRxaobyxUJu0FV6HLkkuJwvoNLVkHwkexLvoaLhb9BmtoQydlIYL5pk2O3jcKaGtWJ83JwTB4A==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -534,13 +534,13 @@
}
},
"node_modules/@inertiajs/vue3": {
"version": "2.3.23",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.23.tgz",
"integrity": "sha512-W6rhjNCIKQQYZsNFdlFEa+prATbXcIR5k9VMg3PsYfRmqTYTORZspdrTH7JLPMneLuhNiI/Y6VbFR0TxGdv5Fg==",
"version": "2.3.21",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.21.tgz",
"integrity": "sha512-gJuOD9HrB6WXpTCUB6yLDHA2yI5YGzhYcGlHCPB6mzt6Lvm7CsQA06CNOyk8eEooz0MYJhFF2V092hU1i866qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inertiajs/core": "2.3.23",
"@inertiajs/core": "2.3.21",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "^1.0.2",
"lodash-es": "^4.18.1"
@ -640,9 +640,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
@ -654,9 +654,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@ -668,9 +668,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@ -682,9 +682,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@ -696,9 +696,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
"arm64"
],
@ -710,9 +710,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@ -724,9 +724,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@ -738,9 +738,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
"arm"
],
@ -752,9 +752,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@ -766,9 +766,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@ -780,9 +780,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
@ -794,9 +794,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"cpu": [
"loong64"
],
@ -808,9 +808,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
@ -822,9 +822,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"cpu": [
"ppc64"
],
@ -836,9 +836,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"cpu": [
"riscv64"
],
@ -850,9 +850,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"cpu": [
"riscv64"
],
@ -864,9 +864,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@ -878,9 +878,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@ -892,9 +892,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@ -906,9 +906,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
@ -920,9 +920,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@ -934,9 +934,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@ -948,9 +948,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
@ -962,9 +962,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@ -976,9 +976,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
@ -1003,49 +1003,49 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
"tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
"@tailwindcss/oxide-android-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-x64": "4.2.4",
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [
"arm64"
],
@ -1060,9 +1060,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [
"arm64"
],
@ -1077,9 +1077,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [
"x64"
],
@ -1094,9 +1094,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [
"x64"
],
@ -1111,9 +1111,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [
"arm"
],
@ -1128,9 +1128,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [
"arm64"
],
@ -1145,9 +1145,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [
"arm64"
],
@ -1162,9 +1162,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [
"x64"
],
@ -1179,9 +1179,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [
"x64"
],
@ -1196,9 +1196,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@ -1214,10 +1214,10 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4",
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
@ -1226,9 +1226,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [
"arm64"
],
@ -1243,9 +1243,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [
"x64"
],
@ -1260,15 +1260,15 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
"integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"tailwindcss": "4.3.0"
"@tailwindcss/node": "4.2.4",
"@tailwindcss/oxide": "4.2.4",
"tailwindcss": "4.2.4"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
@ -1330,111 +1330,111 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34",
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.34",
"@vue/shared": "3.5.34"
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/compiler-core": "3.5.34",
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34",
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33",
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.14",
"postcss": "^8.5.10",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/shared": "3.5.34"
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.34"
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.34",
"@vue/shared": "3.5.34"
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.34",
"@vue/runtime-core": "3.5.34",
"@vue/shared": "3.5.34",
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
"@vue/shared": "3.5.33",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34"
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"vue": "3.5.34"
"vue": "3.5.33"
}
},
"node_modules/@vue/shared": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"dev": true,
"license": "MIT"
},
@ -1560,9 +1560,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
"integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -1638,9 +1638,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"dev": true,
"funding": [
{
@ -1804,9 +1804,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.353",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
"dev": true,
"license": "ISC"
},
@ -1818,9 +1818,9 @@
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.21.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz",
"integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==",
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2179,9 +2179,9 @@
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
@ -2632,9 +2632,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@ -2716,9 +2716,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2732,31 +2732,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.3",
"@rollup/rollup-android-arm64": "4.60.3",
"@rollup/rollup-darwin-arm64": "4.60.3",
"@rollup/rollup-darwin-x64": "4.60.3",
"@rollup/rollup-freebsd-arm64": "4.60.3",
"@rollup/rollup-freebsd-x64": "4.60.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
"@rollup/rollup-linux-arm64-musl": "4.60.3",
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
"@rollup/rollup-linux-loong64-musl": "4.60.3",
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
"@rollup/rollup-linux-x64-gnu": "4.60.3",
"@rollup/rollup-linux-x64-musl": "4.60.3",
"@rollup/rollup-openbsd-x64": "4.60.3",
"@rollup/rollup-openharmony-arm64": "4.60.3",
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
"@rollup/rollup-win32-x64-gnu": "4.60.3",
"@rollup/rollup-win32-x64-msvc": "4.60.3",
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
@ -2914,9 +2914,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"dev": true,
"license": "MIT"
},
@ -3000,9 +3000,9 @@
}
},
"node_modules/vite": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3114,17 +3114,17 @@
}
},
"node_modules/vue": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34",
"@vue/runtime-dom": "3.5.34",
"@vue/server-renderer": "3.5.34",
"@vue/shared": "3.5.34"
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
"@vue/runtime-dom": "3.5.33",
"@vue/server-renderer": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"typescript": "*"

View file

@ -2,7 +2,6 @@
import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
const MASTER_ID = 'master'
@ -43,7 +42,6 @@ const searchQuery = ref('')
const selectedSongId = ref('')
const dropdownOpen = ref(false)
const assignError = ref('')
const ccliDialogOpen = ref(false)
function normalize(value) {
return (value ?? '').toString().toLowerCase().trim()
@ -541,7 +539,6 @@ function closeOnBackdrop(e) {
<p v-if="assignError" class="text-sm text-red-600">{{ assignError }}</p>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
@ -550,25 +547,6 @@ function closeOnBackdrop(e) {
>
Zuordnen
</button>
<a
v-if="searchQuery"
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(searchQuery)"
target="_blank"
rel="noopener noreferrer"
data-testid="songselect-search-button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
>
Auf SongSelect suchen
</a>
<button
type="button"
data-testid="open-ccli-paste-dialog-button"
@click="ccliDialogOpen = true"
class="inline-flex items-center justify-center rounded-md border border-blue-300 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 shadow-sm transition hover:bg-blue-100"
>
Aus CCLI importieren
</button>
</div>
</div>
</div>
@ -757,15 +735,6 @@ function closeOnBackdrop(e) {
</div>
</Transition>
</Teleport>
<!-- CCLI Paste Dialog -->
<CcliPasteDialog
:open="ccliDialogOpen"
mode="service-form"
:service-song-id="props.serviceSongId"
@close="ccliDialogOpen = false"
@imported="(songId) => { ccliDialogOpen = false; router.reload({ only: ['service'] }) }"
/>
</template>
<style scoped>

View file

@ -1,248 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'songdb' }, // 'songdb' | 'service-form' | 'pair-translation'
serviceSongId: { type: Number, default: null },
pairWithSongId: { type: Number, default: null },
prefilledText: { type: String, default: null },
})
const emit = defineEmits(['close', 'imported', 'paired'])
const pasteText = ref('')
const preview = ref(null)
const error = ref(null)
const loading = ref(false)
const existingSongId = ref(null)
onMounted(() => {
if (props.prefilledText) {
pasteText.value = props.prefilledText
doPreview()
}
})
function getCsrfToken() {
const match = document.cookie.split('; ').find(r => r.startsWith('XSRF-TOKEN='))
return match ? decodeURIComponent(match.split('=')[1]) : ''
}
async function doPreview() {
if (!pasteText.value.trim()) return
loading.value = true
error.value = null
preview.value = null
existingSongId.value = null
try {
const res = await fetch(route('api.ccli.preview'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': getCsrfToken() },
body: JSON.stringify({ raw_text: pasteText.value }),
})
const data = await res.json()
if (!res.ok) {
error.value = data.message || 'Fehler beim Verarbeiten des Textes.'
} else {
preview.value = data
}
} catch {
error.value = 'Netzwerkfehler. Bitte versuche es erneut.'
} finally {
loading.value = false
}
}
async function doImport(importMode) {
loading.value = true
error.value = null
existingSongId.value = null
const modeMap = {
edit: 'create',
stay: 'create',
assign: 'assign-to-service-song',
pair: 'pair-with-song',
}
const targetMap = {
assign: props.serviceSongId,
pair: props.pairWithSongId,
}
try {
const res = await fetch(route('api.ccli.import'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': getCsrfToken() },
body: JSON.stringify({
raw_text: pasteText.value,
mode: modeMap[importMode],
target_id: targetMap[importMode] ?? null,
}),
})
const data = await res.json()
if (res.status === 409) {
existingSongId.value = data.existing_song_id
error.value = data.message
return
}
if (!res.ok) {
error.value = data.message || 'Import fehlgeschlagen.'
return
}
if (importMode === 'edit') {
router.visit('/songs/' + data.song_id)
} else if (importMode === 'pair') {
router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true')
} else {
emit('imported', data.song_id, importMode)
emit('close')
}
} catch {
error.value = 'Netzwerkfehler. Bitte versuche es erneut.'
} finally {
loading.value = false
}
}
</script>
<template>
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
{{ mode === 'pair-translation' ? 'Übersetzung aus SongSelect übernehmen' : 'Song aus SongSelect importieren' }}
</h2>
<button
data-testid="ccli-close-button"
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Instructions -->
<ol class="text-sm text-gray-600 mb-4 space-y-1 list-decimal list-inside">
<li>Öffne die Liedseite auf <strong>songselect.ccli.com</strong></li>
<li>Markiere alles (<kbd class="px-1 py-0.5 bg-gray-100 rounded text-xs">Strg+A</kbd>) und kopiere (<kbd class="px-1 py-0.5 bg-gray-100 rounded text-xs">Strg+C</kbd>)</li>
<li>Füge den Text unten ein und klicke <strong>Vorschau</strong></li>
</ol>
<!-- Textarea -->
<textarea
data-testid="ccli-paste-textarea"
v-model="pasteText"
rows="10"
class="w-full border border-gray-300 rounded-md p-3 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Liedtext hier einfügen..."
/>
<!-- Preview button + spinner -->
<div class="mt-3 flex items-center gap-3">
<button
data-testid="ccli-preview-button"
@click="doPreview"
:disabled="!pasteText.trim() || loading"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Vorschau
</button>
<span
v-if="loading"
data-testid="ccli-loading-spinner"
class="inline-block animate-spin text-blue-600 text-xl"
></span>
</div>
<!-- Error message -->
<div
v-if="error"
data-testid="ccli-error-message"
class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700"
>
{{ error }}
<a
v-if="existingSongId"
:href="'/songs/' + existingSongId"
data-testid="ccli-existing-song-link"
class="ml-2 underline font-medium"
>Vorhandenen Song bearbeiten</a>
</div>
<!-- Preview pane -->
<div v-if="preview" class="mt-4 p-4 bg-gray-50 rounded-md border border-gray-200">
<div class="grid grid-cols-2 gap-2 text-sm mb-3">
<div><span class="text-gray-500">Titel:</span> <strong>{{ preview.title }}</strong></div>
<div><span class="text-gray-500">Autor:</span> {{ preview.author || '' }}</div>
<div><span class="text-gray-500">CCLI-Nr.:</span> {{ preview.ccliId || '' }}</div>
<div><span class="text-gray-500">Jahr:</span> {{ preview.year || '' }}</div>
</div>
<div class="text-xs text-gray-500">
Sektionen: {{ preview.sections?.map(s => s.label).join(', ') }}
</div>
</div>
<!-- Action buttons (shown after preview) -->
<div v-if="preview" class="mt-4 flex gap-3">
<!-- songdb mode -->
<template v-if="mode === 'songdb'">
<button
data-testid="ccli-import-edit-button"
@click="doImport('edit')"
:disabled="loading"
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700 disabled:opacity-50"
>
Importieren &amp; Bearbeiten
</button>
<button
data-testid="ccli-import-stay-button"
@click="doImport('stay')"
:disabled="loading"
class="px-4 py-2 bg-gray-600 text-white rounded-md text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
Importieren
</button>
</template>
<!-- service-form mode -->
<template v-else-if="mode === 'service-form'">
<button
data-testid="ccli-import-edit-button"
@click="doImport('edit')"
:disabled="loading"
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700 disabled:opacity-50"
>
Importieren &amp; Bearbeiten
</button>
<button
data-testid="ccli-import-assign-button"
@click="doImport('assign')"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
Importieren &amp; Zuweisen
</button>
</template>
<!-- pair-translation mode -->
<template v-else-if="mode === 'pair-translation'">
<button
data-testid="ccli-pair-translation-button"
@click="doImport('pair')"
:disabled="loading"
class="px-4 py-2 bg-purple-600 text-white rounded-md text-sm font-medium hover:bg-purple-700 disabled:opacity-50"
>
Übersetzung übernehmen
</button>
</template>
</div>
</div>
</div>
</template>

View file

@ -4,8 +4,8 @@ import AgendaSettings from './Settings/AgendaSettings.vue'
import LabelImport from './Settings/LabelImport.vue'
import MacroAssignments from './Settings/MacroAssignments.vue'
import MacroImport from './Settings/MacroImport.vue'
import { Head, router } from '@inertiajs/vue3'
import { computed, onMounted, ref } from 'vue'
import { Head } from '@inertiajs/vue3'
import { onMounted, ref } from 'vue'
const props = defineProps({
settings: { type: Object, default: () => ({}) },
@ -22,7 +22,6 @@ const submenus = [
{ key: 'macros', label: 'Makro-Import' },
{ key: 'labels', label: 'Label-Import' },
{ key: 'agenda', label: 'Agenda' },
{ key: 'ccli', label: 'CCLI Import' },
]
const activeSubmenu = ref('assignments')
@ -38,35 +37,6 @@ function switchSubmenu(key) {
activeSubmenu.value = key
window.location.hash = key
}
// Fetch bookmarklet href from the server endpoint
const bookmarkletHref = ref('javascript:void(0)')
onMounted(async () => {
try {
const res = await fetch(route('bookmarklets.ccli'))
if (res.ok) {
bookmarkletHref.value = await res.text()
}
} catch {
// fallback: keep void
}
})
async function updateSetting(key, value) {
try {
await fetch(route('settings.update'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.split('; ').find(r => r.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? ''),
'Accept': 'application/json',
},
body: JSON.stringify({ key, value }),
})
} catch {
// silent fail
}
}
</script>
<template>
@ -152,76 +122,6 @@ async function updateSetting(key, value) {
v-if="activeSubmenu === 'agenda'"
:settings="settings"
/>
<!-- CCLI Import Settings -->
<div v-if="activeSubmenu === 'ccli'" class="space-y-6">
<div>
<h3 class="text-base font-semibold text-gray-900">CCLI SongSelect Import</h3>
<p class="mt-1 text-sm text-gray-500">
Importiere Songs direkt aus SongSelect in deine Song-Datenbank.
</p>
</div>
<!-- Default Translation Language -->
<div class="rounded-lg border border-gray-200 p-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Standard-Übersetzungssprache
</label>
<p class="text-xs text-gray-500 mb-3">
Wenn ein Song auf SongSelect in mehreren Sprachen verfügbar ist, wird diese Sprache als Übersetzung importiert.
</p>
<select
data-testid="default-translation-language"
:value="settings.default_translation_language || 'DE'"
@change="updateSetting('default_translation_language', $event.target.value)"
class="block w-48 rounded-md border-gray-300 text-sm shadow-sm focus:border-amber-500 focus:ring-amber-500"
>
<option value="DE">Deutsch (DE)</option>
<option value="EN">Englisch (EN)</option>
<option value="FR">Französisch (FR)</option>
<option value="ES">Spanisch (ES)</option>
<option value="NL">Niederländisch (NL)</option>
<option value="IT">Italienisch (IT)</option>
</select>
</div>
<!-- Bookmarklet Installer -->
<div class="rounded-lg border border-blue-100 bg-blue-50 p-4">
<h4 class="text-sm font-semibold text-blue-900 mb-2">Browser-Lesezeichen installieren</h4>
<p class="text-sm text-blue-800 mb-3">
Mit diesem Lesezeichen kannst du Songs direkt von SongSelect in pp-planer importieren ohne Copy-Paste.
</p>
<ol class="text-sm text-blue-800 space-y-1 list-decimal list-inside mb-4">
<li>Ziehe den Button unten in deine Lesezeichen-Leiste</li>
<li>Öffne ein Lied auf <strong>songselect.ccli.com</strong> (du musst eingeloggt sein)</li>
<li>Klicke das Lesezeichen der Liedtext wird automatisch übertragen</li>
<li>Klicke auf Importieren" fertig!</li>
</ol>
<p class="text-xs text-blue-600 mb-3">
Lesezeichen-Leiste aktivieren: <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+Umschalt+B</kbd> (Mac: <kbd class="px-1 py-0.5 bg-blue-100 rounded">Cmd+Shift+B</kbd>)
</p>
<a
data-testid="ccli-bookmarklet-drag-link"
:href="bookmarkletHref"
class="inline-flex items-center gap-2 rounded-md border border-blue-300 bg-white px-4 py-2 text-sm font-medium text-blue-700 shadow-sm cursor-grab hover:bg-blue-50"
@click.prevent
draggable="true"
>
📥 CCLI Import
</a>
<p class="mt-2 text-xs text-blue-500">
Diesen Button in die Lesezeichen-Leiste ziehen.
</p>
<!-- Troubleshooting -->
<details class="mt-4">
<summary class="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Was tun, wenn der Liedtext nicht erkannt wird?</summary>
<p class="mt-2 text-xs text-blue-700">
Falle zurück auf das manuelle Einfügen: Öffne das Lied auf SongSelect, drücke <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+A</kbd> dann <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+C</kbd>, und klicke dann auf Aus CCLI importieren" in der Song-Datenbank oder im Gottesdienst-Formular.
</p>
</details>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,56 +0,0 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
import { Head, router } from '@inertiajs/vue3'
const props = defineProps({
prefilledText: { type: String, default: null },
prefilledMetadata: { type: Object, default: null },
prefillError: { type: String, default: null },
})
function handleClose() {
router.visit(route('songs.index'))
}
function handleImported(songId, mode) {
if (mode === 'stay') {
router.visit(route('songs.index'))
}
}
</script>
<template>
<Head title="Song aus SongSelect importieren" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-900">
Song aus SongSelect importieren
</h2>
</template>
<div class="py-8">
<div class="mx-auto max-w-2xl px-4 sm:px-6 lg:px-8">
<!-- Prefill error -->
<div
v-if="prefillError"
data-testid="ccli-prefill-error-message"
class="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800"
>
{{ prefillError }}
</div>
<!-- Always-open paste dialog on this page -->
<CcliPasteDialog
:open="true"
mode="songdb"
:prefilled-text="prefilledText"
@close="handleClose"
@imported="handleImported"
/>
</div>
</div>
</AuthenticatedLayout>
</template>

View file

@ -1,8 +1,7 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import SongEditModal from '@/Components/SongEditModal.vue'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
import { Head, router } from '@inertiajs/vue3'
import { Head } from '@inertiajs/vue3'
import axios from 'axios'
import { ref, watch, onMounted } from 'vue'
@ -23,7 +22,6 @@ const fileInput = ref(null)
// Edit modal state
const showEditModal = ref(false)
const editSongId = ref(null)
const ccliDialogOpen = ref(false)
let debounceTimer = null
// Preview modal state
@ -389,28 +387,6 @@ function pageRange() {
</Transition>
</div>
<!-- CCLI Import Buttons -->
<div class="mb-4 flex items-center gap-2">
<a
v-if="search"
:href="'https://songselect.ccli.com/Search/Results?searchText=' + encodeURIComponent(search)"
target="_blank"
rel="noopener noreferrer"
data-testid="songselect-search-button-songdb"
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
>
Auf SongSelect suchen
</a>
<button
type="button"
data-testid="open-ccli-paste-dialog-button-songdb"
@click="ccliDialogOpen = true"
class="inline-flex items-center gap-1 rounded-md border border-blue-300 bg-blue-50 px-3 py-1.5 text-sm font-medium text-blue-700 shadow-sm transition hover:bg-blue-100"
>
Aus CCLI importieren
</button>
</div>
<!-- Song Count + Loading -->
<div class="mb-3 flex items-center justify-between px-1">
<p class="text-xs font-medium text-gray-500">
@ -782,12 +758,4 @@ function pageRange() {
</Teleport>
</AuthenticatedLayout>
<!-- CCLI Paste Dialog -->
<CcliPasteDialog
:open="ccliDialogOpen"
mode="songdb"
@close="ccliDialogOpen = false"
@imported="(songId, mode) => { ccliDialogOpen = false; if (mode === 'stay') { router.reload({ only: ['songs'] }) } }"
/>
</template>

View file

@ -1,34 +1,21 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, router } from '@inertiajs/vue3'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
const props = defineProps({
song: {
type: Object,
required: true,
},
prefilledTranslation: {
type: String,
default: null,
},
})
const sourceUrl = ref('')
const sourceText = ref(props.prefilledTranslation ?? '')
const sourceText = ref('')
const isFetching = ref(false)
const isSaving = ref(false)
const infoMessage = ref('')
const errorMessage = ref('')
const showPrefillBanner = ref(props.prefilledTranslation !== null)
// Auto-distribute prefilled translation on mount
onMounted(() => {
if (props.prefilledTranslation) {
distributeTextToSlides(props.prefilledTranslation)
infoMessage.value = 'Vorausgefüllt aus CCLI — bitte überprüfen und speichern.'
}
})
const groups = ref(
(props.song.groups ?? []).map((group) => ({
@ -186,24 +173,6 @@ function rowsForSlide(slide) {
<div class="py-8">
<div class="mx-auto max-w-6xl space-y-6 px-4 sm:px-6 lg:px-8">
<!-- CCLI Prefill Banner -->
<div
v-if="showPrefillBanner"
data-testid="ccli-prefill-banner"
class="flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800"
>
<span>Vorausgefüllt aus CCLI bitte überprüfen und speichern.</span>
<button
data-testid="ccli-prefill-discard-button"
type="button"
@click="showPrefillBanner = false; sourceText = ''; groups.forEach(g => g.slides.forEach(s => s.translated_text = ''))"
class="ml-4 text-blue-600 underline hover:text-blue-800"
>
Verwerfen
</button>
</div>
<section class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 class="text-base font-semibold text-gray-900">Uebersetzungstext laden</h3>
<p class="mt-1 text-sm text-gray-500">

View file

@ -1,6 +1,5 @@
<?php
use App\Http\Controllers\CcliPasteController;
use App\Http\Controllers\ProFileController;
use App\Http\Controllers\ServiceSongController;
use App\Http\Controllers\SongController;
@ -48,12 +47,4 @@
Route::get('/songs/{song}/download-pro', [ProFileController::class, 'downloadPro'])
->name('api.songs.download-pro');
// CCLI Paste Import (manuelles Einfügen oder Bookmarklet)
Route::middleware('throttle:30,1')->group(function () {
Route::post('/ccli/preview', [CcliPasteController::class, 'preview'])
->name('api.ccli.preview');
Route::post('/songs/import-from-ccli-paste', [CcliPasteController::class, 'importPaste'])
->name('api.ccli.import');
});
});

View file

@ -2,8 +2,6 @@
use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarkletController;
use App\Http\Controllers\CcliPasteController;
use App\Http\Controllers\LabelImportController;
use App\Http\Controllers\MacroAssignmentController;
use App\Http\Controllers\MacroImportController;
@ -48,8 +46,6 @@
->middleware('auth')
->name('logout');
Route::get('/bookmarklets/ccli-import.js', [BookmarkletController::class, 'show'])->name('bookmarklets.ccli');
Route::middleware('auth')->group(function () {
Route::get('/', function () {
return redirect()->route('dashboard');
@ -73,9 +69,6 @@
return Inertia::render('Songs/Index');
})->name('songs.index');
Route::get('/songs/import-from-ccli-paste', [CcliPasteController::class, 'showImportPage'])
->name('songs.import-from-ccli-paste');
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body');

View file

@ -1,41 +0,0 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bookmarklet endpoint returns 200 with text/javascript content type', function () {
$response = $this->get('/bookmarklets/ccli-import.js');
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
});
test('bookmarklet response starts with javascript: prefix', function () {
$response = $this->get('/bookmarklets/ccli-import.js');
expect($response->getContent())->toStartWith('javascript:');
});
test('bookmarklet response is a single line with no actual newlines', function () {
$response = $this->get('/bookmarklets/ccli-import.js');
$content = $response->getContent();
expect(substr_count($content, "\n"))->toBe(0);
});
test('bookmarklet response contains app URL and import path', function () {
$response = $this->get('/bookmarklets/ccli-import.js');
$content = $response->getContent();
expect($content)->toContain('import-from-ccli-paste');
expect($content)->toContain('songselect.ccli.com');
expect($content)->toContain('btoa');
});
test('bookmarklet endpoint does not require authentication', function () {
$response = $this->get('/bookmarklets/ccli-import.js');
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'text/javascript; charset=utf-8');
});

View file

@ -1,66 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
test('ccli fixture corpus has at least 20 txt files', function (): void {
$files = glob(base_path('tests/fixtures/ccli/*.txt'));
expect($files)->not->toBeEmpty();
expect(count($files))->toBeGreaterThanOrEqual(20);
});
test('each ccli fixture is valid utf8 with section labels and title', function (): void {
$files = glob(base_path('tests/fixtures/ccli/*.txt'));
expect($files)->not->toBeEmpty();
foreach ($files as $file) {
$content = File::get($file);
$basename = basename($file);
expect(mb_detect_encoding($content, 'UTF-8', true))->toBe('UTF-8', $basename.' not UTF-8');
expect(strlen($content))->toBeGreaterThanOrEqual(100, $basename.' too small');
expect(strlen($content))->toBeLessThanOrEqual(50000, $basename.' too large');
expect((bool) preg_match('/^(Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)/im', $content))->toBeTrue($basename.' has no section label');
$lines = preg_split('/\r\n|\n|\r/', $content);
$firstNonEmpty = '';
foreach ($lines as $line) {
if (trim($line) !== '') {
$firstNonEmpty = trim($line);
break;
}
}
expect($firstNonEmpty)->not->toBeEmpty($basename.' has no title line');
}
});
test('ccli fixture corpus covers german labels', function (): void {
$files = glob(base_path('tests/fixtures/ccli/*.txt'));
$germanLabelFound = false;
foreach ($files as $file) {
if (preg_match('/Strophe|Refrain|Brücke/i', File::get($file))) {
$germanLabelFound = true;
break;
}
}
expect($germanLabelFound)->toBeTrue('No fixture with German labels found');
});
test('ccli fixture corpus covers repeat markers', function (): void {
$files = glob(base_path('tests/fixtures/ccli/*.txt'));
$repeatFound = false;
foreach ($files as $file) {
if (preg_match('/\(Repeat\)|[xX]\s*\d+/i', File::get($file))) {
$repeatFound = true;
break;
}
}
expect($repeatFound)->toBeTrue('No fixture with repeat markers found');
});

View file

@ -1,133 +0,0 @@
<?php
use App\Exceptions\DuplicateCcliSongException;
use App\Models\ApiRequestLog;
use App\Models\Song;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Services\CcliImportService;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function ccliFixture(string $name): string
{
return file_get_contents(base_path("tests/fixtures/ccli/{$name}"));
}
test('imports english-only fixture and creates song with default arrangement', function () {
$service = app(CcliImportService::class);
$result = $service->import(
ccliFixture('english-only-multi-verse.txt'),
'https://songselect.ccli.com/Songs/9999001',
);
expect($result['status'])->toBe('created')
->and($result['warnings'])->toBeArray()
->and($result['song'])->toBeInstanceOf(Song::class);
$song = $result['song']->fresh();
expect($song->title)->toBe('Test Song 1')
->and($song->author)->toBe('Test Artist 1')
->and($song->ccli_id)->toBe('9999001')
->and($song->copyright_year)->toBe('2024')
->and($song->has_translation)->toBeFalse()
->and($song->imported_from_ccli_at)->not->toBeNull()
->and($song->ccli_source_url)->toBe('https://songselect.ccli.com/Songs/9999001');
$arrangement = $song->arrangements()->where('name', 'normal')->first();
expect($arrangement)->not->toBeNull()
->and($arrangement->is_default)->toBeTrue()
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5)
->and(SongSlide::count())->toBe(9);
});
test('imports english and german fixture and stores translated slide text', function () {
$service = app(CcliImportService::class);
$result = $service->import(ccliFixture('english-german-side-by-side.txt'));
$song = $result['song']->fresh();
expect($result['status'])->toBe('created')
->and($song->has_translation)->toBeTrue()
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(4)
->and(SongSlide::where('text_content_translated', 'Deutsche Liedzeile 1 zum gleichen Gedanken')->exists())->toBeTrue();
});
test('blocks active duplicate ccli_id with DuplicateCcliSongException', function () {
$service = app(CcliImportService::class);
$first = $service->import(ccliFixture('english-only-multi-verse.txt'));
expect(fn () => $service->import(ccliFixture('english-only-multi-verse.txt')))
->toThrow(DuplicateCcliSongException::class);
try {
$service->import(ccliFixture('english-only-multi-verse.txt'));
} catch (DuplicateCcliSongException $exception) {
expect($exception->existingSongId)->toBe($first['song']->id)
->and($exception->getMessage())->toContain('existiert bereits');
}
expect(Song::count())->toBe(1);
});
test('restores soft-deleted song and does not duplicate normal arrangement', function () {
$service = app(CcliImportService::class);
$first = $service->import(ccliFixture('english-only-multi-verse.txt'));
$songId = $first['song']->id;
Song::find($songId)->delete();
$result = $service->import(ccliFixture('english-only-multi-verse.txt'));
$restoredSong = Song::withTrashed()->find($songId);
expect($result['status'])->toBe('restored')
->and($result['song']->id)->toBe($songId)
->and($restoredSong->trashed())->toBeFalse()
->and($restoredSong->arrangements()->where('name', 'normal')->count())->toBe(1);
});
test('throws RuntimeException when paste has no ccli id', function () {
$content = "Test Song Title\nTest Artist\n\nVerse 1\nSome lyrics here\n\nChorus\nChorus lyrics\n\n© 2024 Publisher";
$service = app(CcliImportService::class);
expect(fn () => $service->import($content))->toThrow(RuntimeException::class);
expect(Song::count())->toBe(0);
});
test('import creates ApiRequestLog with metadata only and no lyrics body', function () {
$service = app(CcliImportService::class);
$service->import(ccliFixture('english-only-multi-verse.txt'));
$log = ApiRequestLog::latest()->first();
expect($log)->not->toBeNull()
->and($log->method)->toBe('import')
->and($log->endpoint)->toBe('paste')
->and($log->status)->toBe('success')
->and($log->request_context)->toMatchArray(['ccli_id' => '9999001', 'mode' => 'created'])
->and($log->response_summary)->toBe('Song created: Test Song 1')
->and($log->response_body)->toBeNull()
->and($log->response_summary)->not->toContain('Morning light breaks');
});
test('rolls back song and log when slide creation fails', function () {
DB::statement("CREATE TRIGGER fail_ccli_slide_insert BEFORE INSERT ON song_slides BEGIN SELECT RAISE(ABORT, 'slide creation failed'); END");
try {
expect(fn () => app(CcliImportService::class)->import(ccliFixture('english-only-multi-verse.txt')))
->toThrow(QueryException::class);
} finally {
DB::statement('DROP TRIGGER IF EXISTS fail_ccli_slide_insert');
}
expect(Song::count())->toBe(0)
->and(SongSlide::count())->toBe(0)
->and(ApiRequestLog::count())->toBe(0);
});

View file

@ -1,171 +0,0 @@
<?php
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function ccliPasteControllerFixture(string $name): string
{
return file_get_contents(base_path("tests/fixtures/ccli/{$name}"));
}
test('preview returns parsed DTO without writing to DB', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$songCountBefore = Song::count();
$response = $this->actingAs($user)
->postJson(route('api.ccli.preview'), ['raw_text' => $content]);
$response->assertStatus(200);
$response->assertJsonStructure(['title', 'author', 'ccliId', 'year', 'copyrightText', 'sections']);
expect($response->json('title'))->toBe('Test Song 1');
expect($response->json('ccliId'))->toBe('9999001');
expect(Song::count())->toBe($songCountBefore);
});
test('importPaste create mode persists song and returns 201', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => $content,
'mode' => 'create',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['song_id', 'status', 'warnings']);
expect($response->json('status'))->toBe('created');
expect(Song::find($response->json('song_id')))->not->toBeNull();
});
test('importPaste returns 409 on duplicate ccli_id with existing_song_id', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$first = $this->actingAs($user)
->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']);
$existingId = $first->json('song_id');
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']);
$response->assertStatus(409);
$response->assertJsonStructure(['message', 'existing_song_id', 'edit_url']);
expect($response->json('existing_song_id'))->toBe($existingId);
expect($response->json('message'))->toContain('existiert bereits');
});
test('importPaste returns 422 on malformed paste with German error', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => 'This has no section labels at all',
'mode' => 'create',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['message']);
expect($response->json('message'))->not->toBeEmpty();
});
test('importPaste returns 422 on empty raw_text', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => '',
'mode' => 'create',
]);
$response->assertStatus(422);
});
test('importPaste assign-to-service-song imports and assigns song to service song', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$service = Service::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'service_id' => $service->id,
'song_id' => null,
'song_arrangement_id' => null,
'matched_at' => null,
]);
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => $content,
'mode' => 'assign-to-service-song',
'target_id' => $serviceSong->id,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['song_id', 'service_song_id', 'status']);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($response->json('song_id'));
expect($serviceSong->matched_at)->not->toBeNull();
});
test('importPaste rejects unauthenticated requests with 401', function () {
$response = $this->postJson(route('api.ccli.import'), [
'raw_text' => 'test',
'mode' => 'create',
]);
$response->assertUnauthorized();
});
test('showImportPage renders Inertia page with prefilled data from valid base64', function () {
$user = User::factory()->create();
$this->withoutVite();
$payload = json_encode([
'title' => 'Amazing Grace',
'author' => 'John Newton',
'ccliId' => '4760',
'sourceUrl' => 'https://songselect.ccli.com/Songs/4760',
'rawText' => "Amazing Grace\nJohn Newton\n\nVerse 1\nAmazing grace how sweet the sound\n\n© 1779\nCCLI # 4760",
]);
$encoded = base64_encode($payload);
$response = $this->actingAs($user)
->get(route('songs.import-from-ccli-paste', ['prefill' => $encoded]));
$response->assertStatus(200);
$response->assertInertia(
fn ($page) => $page->component('Songs/ImportFromCcliPaste', false)
->has('prefilledText')
->has('prefilledMetadata')
->where('prefilledMetadata.title', 'Amazing Grace')
->where('prefilledMetadata.ccliId', '4760')
);
});
test('showImportPage renders with error for invalid base64', function () {
$user = User::factory()->create();
$this->withoutVite();
$response = $this->actingAs($user)
->get(route('songs.import-from-ccli-paste', ['prefill' => 'NOT_VALID_BASE64!!!']));
$response->assertStatus(200);
$response->assertInertia(
fn ($page) => $page->component('Songs/ImportFromCcliPaste', false)
->where('prefillError', fn ($v) => $v !== null)
);
});
test('showImportPage redirects unauthenticated to login', function () {
$response = $this->get(route('songs.import-from-ccli-paste'));
$response->assertRedirect(route('login'));
});

View file

@ -1,102 +0,0 @@
<?php
use App\Services\CcliPasteParser;
use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong;
test('CcliPasteParser can be instantiated with no arguments', function (): void {
$parser = new CcliPasteParser;
expect($parser)->toBeInstanceOf(CcliPasteParser::class);
});
test('CcliPasteParser can be instantiated with closure injections', function (): void {
$parser = new CcliPasteParser(
sectionDetector: fn (string $line): bool => str_starts_with($line, 'Verse'),
metadataDetector: fn (string $line): bool => str_contains($line, '©'),
);
expect($parser)->toBeInstanceOf(CcliPasteParser::class);
});
test('CcliPasteParser resolves from Laravel container', function (): void {
$parser = app(CcliPasteParser::class);
expect($parser)->toBeInstanceOf(CcliPasteParser::class);
});
test('CcliPasteParser::parse returns ParsedCcliSong DTO', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nSome text");
expect($result)->toBeInstanceOf(ParsedCcliSong::class);
});
test('ParsedCcliSong DTO has all required properties', function (): void {
$song = new ParsedCcliSong(
title: 'Test Song',
author: 'Test Author',
ccliId: '9999001',
year: '2024',
copyrightText: '© 2024 Test',
sourceUrl: 'https://songselect.ccli.com/Songs/9999001',
sections: [],
);
expect($song->title)->toBe('Test Song');
expect($song->author)->toBe('Test Author');
expect($song->ccliId)->toBe('9999001');
expect($song->year)->toBe('2024');
expect($song->copyrightText)->toBe('© 2024 Test');
expect($song->sourceUrl)->toContain('songselect.ccli.com');
expect($song->sections)->toBeArray();
});
test('ParsedCcliSection DTO has all required properties including linesTranslated', function (): void {
$section = new ParsedCcliSection(
label: 'Verse 1',
kind: 'Verse',
number: '1',
modifier: null,
lines: ['Line 1', 'Line 2'],
linesTranslated: ['Zeile 1', 'Zeile 2'],
);
expect($section->label)->toBe('Verse 1');
expect($section->kind)->toBe('Verse');
expect($section->number)->toBe('1');
expect($section->modifier)->toBeNull();
expect($section->lines)->toBe(['Line 1', 'Line 2']);
expect($section->linesTranslated)->toBe(['Zeile 1', 'Zeile 2']);
});
test('ParsedCcliSection linesTranslated defaults to null', function (): void {
$section = new ParsedCcliSection(
label: 'Chorus',
kind: 'Chorus',
number: null,
modifier: null,
lines: ['Line 1'],
);
expect($section->linesTranslated)->toBeNull();
});
test('CcliPasteParser can be injected with closure override in container', function (): void {
$called = false;
$fakeParser = new CcliPasteParser(
sectionDetector: function () use (&$called): bool {
$called = true;
return true;
},
);
app()->instance(CcliPasteParser::class, $fakeParser);
$resolved = app(CcliPasteParser::class);
expect($resolved)->toBe($fakeParser);
app()->forgetInstance(CcliPasteParser::class);
});

View file

@ -1,145 +0,0 @@
<?php
use App\Services\CcliPasteParser;
use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong;
function ccliFixturePath(string $filename): string
{
return base_path("tests/fixtures/ccli/{$filename}");
}
function ccliFixtureContent(string $filename): string
{
return file_get_contents(ccliFixturePath($filename));
}
test('each fixture parses into a valid ParsedCcliSong DTO', function (): void {
$parser = new CcliPasteParser;
foreach (glob(base_path('tests/fixtures/ccli/*.txt')) as $path) {
$filename = basename($path);
$result = $parser->parse(ccliFixtureContent($filename));
expect($result)->toBeInstanceOf(ParsedCcliSong::class);
expect($result->title)->not->toBeEmpty("Fixture {$filename}: title should not be empty");
expect($result->sections)->not->toBeEmpty("Fixture {$filename}: should have at least one section");
foreach ($result->sections as $section) {
expect($section)->toBeInstanceOf(ParsedCcliSection::class);
expect($section->kind)->not->toBeEmpty("Fixture {$filename}: section kind should not be empty");
expect($section->lines)->not->toBeEmpty("Fixture {$filename}: section should have lines");
}
}
});
test('english-only-multi-verse.txt parses 4+ sections without translation', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('english-only-multi-verse.txt'));
expect(count($result->sections))->toBeGreaterThanOrEqual(4);
expect($result->ccliId)->not->toBeNull();
$hasTranslated = false;
foreach ($result->sections as $section) {
if ($section->linesTranslated !== null) {
$hasTranslated = true;
}
}
expect($hasTranslated)->toBeFalse('English-only should have no linesTranslated');
});
test('english-german-side-by-side.txt extracts both languages per section', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('english-german-side-by-side.txt'));
$translatedSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->linesTranslated !== null);
expect(count($translatedSections))->toBeGreaterThanOrEqual(1, 'Should have at least 1 section with translation');
$first = array_values($translatedSections)[0];
expect($first->lines)->not->toBeEmpty();
expect($first->linesTranslated)->not->toBeEmpty();
});
test('german-only.txt detects German labels and normalizes section kind', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('german-only.txt'));
$labels = array_map(fn (ParsedCcliSection $section): string => $section->label, $result->sections);
$kinds = array_map(fn (ParsedCcliSection $section): string => $section->kind, $result->sections);
expect($labels)->toContain('Strophe 1');
expect($kinds)->toContain('Verse');
expect($kinds)->toContain('Chorus');
});
test('common CCLI metadata formats extract the song ID but not license numbers', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nLine\n\nCCLI Song # 1234567\nCCLI License # 111222");
expect($result->ccliId)->toBe('1234567');
$result = $parser->parse("Test Song\nTest Artist\n\nStrophe 1\nZeile\n\nCCLI-Nr. 7654321");
expect($result->ccliId)->toBe('7654321');
});
test('repeat-marker.txt preserves modifier in section DTO', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('repeat-marker.txt'));
$repeatSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->modifier !== null);
expect(count($repeatSections))->toBeGreaterThanOrEqual(1, 'Should have at least 1 section with Repeat modifier');
});
test('umlauts.txt preserves Unicode characters', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('umlauts.txt'));
$allText = $result->title;
foreach ($result->sections as $section) {
$allText .= implode(' ', $section->lines);
}
expect((bool) preg_match('/[äöüßÄÖÜ]/u', $allText))->toBeTrue('Umlauts should be preserved');
});
test('missing-copyright.txt returns null copyrightText', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('missing-copyright.txt'));
expect($result->ccliId)->not->toBeNull('CCLI ID should still be extracted');
expect($result->copyrightText)->toBeNull('No © line should mean null copyrightText');
expect($result->year)->toBeNull('No © means no year either');
});
test('5-verses.txt handles 5 verse sections correctly', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('5-verses.txt'));
$verseSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => in_array(mb_strtolower($section->kind), ['verse', 'strophe'], true));
expect(count($verseSections))->toBeGreaterThanOrEqual(5, 'Should have 5 verse sections');
});
test('parse throws InvalidArgumentException on empty input', function (): void {
$parser = new CcliPasteParser;
expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class);
});
test('parse throws InvalidArgumentException on text with no section labels', function (): void {
$parser = new CcliPasteParser;
expect(fn () => $parser->parse('Just some random text without any section labels'))->toThrow(InvalidArgumentException::class);
});
test('parse error messages are in German', function (): void {
$parser = new CcliPasteParser;
try {
$parser->parse('');
} catch (InvalidArgumentException $exception) {
expect($exception->getMessage())->toMatch('/[A-Za-zÄÖÜäöü]/u');
expect($exception->getMessage())->not->toContain('Error:');
}
});

View file

@ -1,129 +0,0 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Services\CcliTranslationPairingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeLocalSongForCcliPairing(array $labelConfig): Song
{
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'normal',
'is_default' => true,
]);
foreach ($labelConfig as $order => $config) {
['label_name' => $labelName, 'slide_count' => $slideCount] = $config;
$label = Label::firstOrCreate(
['name' => $labelName],
['color' => '#3B82F6'],
);
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => $order + 1,
]);
for ($i = 0; $i < $slideCount; $i++) {
SongSlide::create([
'label_id' => $label->id,
'order' => $i + 1,
'text_content' => "Original line $i for $labelName",
]);
}
}
return $song;
}
test('pairs matching English labels with CCLI sections', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
['label_name' => 'Verse 2', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nCCLI line 1\nCCLI line 2\n\nChorus\nCCLI chorus\n\nVerse 2\nCCLI v2 line1\nCCLI v2 line2\n\n© 2024 Test\nCCLI # 9999001";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toBeEmpty('All labels should match')
->and($result['mapping'])->toHaveCount(3)
->and($result['distributed_text'])->not->toBeEmpty();
foreach ($result['mapping'] as $entry) {
expect($entry['ccli_label'])->not->toBeNull("Label {$entry['local_label']} should have a CCLI match");
}
});
test('pairs German local labels with English CCLI labels via normalization', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Strophe 1', 'slide_count' => 2],
['label_name' => 'Refrain', 'slide_count' => 2],
['label_name' => 'Strophe 2', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nEN line 1\nEN line 2\n\nChorus\nEN chorus 1\nEN chorus 2\n\nVerse 2\nEN v2 1\nEN v2 2\n\n© 2024 Test\nCCLI # 9999002";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toBeEmpty('German labels should normalize to match English CCLI labels')
->and($result['mapping'])->toHaveCount(3)
->and($result['mapping'][0]['ccli_label'])->toBe('Verse 1')
->and($result['mapping'][1]['ccli_label'])->toBe('Chorus');
});
test('returns unmatched_labels for sections not in CCLI', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
['label_name' => 'Bridge', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nLine 1\nLine 2\n\nChorus\nChorus line\n\n© 2024 Test\nCCLI # 9999003";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toContain('Bridge')
->and($result['mapping'])->toHaveCount(3)
->and($result['mapping'][2]['ccli_label'])->toBeNull()
->and($result['mapping'][2]['distributed_lines'])->toBe(['', '']);
});
test('distributes lines preserving local slide count', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nLine 1\nLine 2\nLine 3\nLine 4\n\n© 2024\nCCLI # 9999004";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['mapping'][0]['distributed_lines'])->toHaveCount(2)
->and($result['distributed_text'])->toContain('Line 4');
});
test('uses linesTranslated from CCLI when available', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
]);
$ccliText = file_get_contents(base_path('tests/fixtures/ccli/english-german-side-by-side.txt'));
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['song'])->toBeInstanceOf(Song::class)
->and($result['mapping'])->toBeArray()
->and($result['distributed_text'])->toContain('Deutsche Liedzeile')
->and($result['distributed_text'])->toContain('Deutscher Refrain');
});

View file

@ -1,87 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Setting;
use App\Models\User;
use Database\Seeders\CcliSettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* @mixin \Illuminate\Foundation\Testing\Concerns\InteractsWithAuthentication
* @mixin \Illuminate\Foundation\Testing\Concerns\InteractsWithDatabase
*/
final class SettingsDefaultLanguageTest extends TestCase
{
use RefreshDatabase;
public function test_default_translation_language_is_seeded_with_de(): void
{
$this->seed(CcliSettingsSeeder::class);
$setting = Setting::where('key', 'default_translation_language')->first();
$this->assertNotNull($setting);
$this->assertSame('DE', $setting?->value);
}
#[DataProvider('validLanguages')]
public function test_accepts_valid_default_translation_language_via_patch(string $language): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->patchJson(route('settings.update'), [
'key' => 'default_translation_language',
'value' => $language,
]);
$response->assertOk()->assertJson(['success' => true]);
$this->assertSame($language, Setting::get('default_translation_language'));
}
public static function validLanguages(): array
{
return [
['DE'],
['EN'],
['FR'],
['ES'],
['NL'],
['IT'],
];
}
public function test_rejects_invalid_default_translation_language_via_patch(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->patchJson(route('settings.update'), [
'key' => 'default_translation_language',
'value' => 'ZZ',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('value');
}
public function test_exposes_default_translation_language_in_settings_props(): void
{
$user = User::factory()->create();
$this->seed(CcliSettingsSeeder::class);
$response = $this->actingAs($user)
->withoutVite()
->get(route('settings.index'));
$response->assertInertia(
fn ($page) => $page
->component('Settings')
->has('settings.default_translation_language')
->where('settings.default_translation_language', 'DE')
);
}
}

View file

@ -1,54 +0,0 @@
<?php
use App\Models\Song;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
test('songs table has imported_from_ccli_at and ccli_source_url columns', function (): void {
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeTrue();
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeTrue();
});
test('imported_from_ccli_at defaults to null', function (): void {
$song = Song::factory()->create();
expect($song->fresh()->imported_from_ccli_at)->toBeNull();
});
test('ccli_source_url defaults to null', function (): void {
$song = Song::factory()->create();
expect($song->fresh()->ccli_source_url)->toBeNull();
});
test('imported_from_ccli_at casts to Carbon instance', function (): void {
$song = Song::factory()->create(['imported_from_ccli_at' => '2026-05-10 12:00:00']);
expect($song->fresh()->imported_from_ccli_at)->toBeInstanceOf(Carbon::class);
});
test('fromCcli factory state populates both fields', function (): void {
$song = Song::factory()->fromCcli()->create();
$fresh = $song->fresh();
expect($fresh->imported_from_ccli_at)->not->toBeNull();
expect($fresh->imported_from_ccli_at)->toBeInstanceOf(Carbon::class);
expect($fresh->ccli_source_url)->not->toBeNull();
expect($fresh->ccli_source_url)->toContain('songselect.ccli.com');
});
test('migration rolls back cleanly', function (): void {
Artisan::call('migrate:rollback', ['--step' => 1]);
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeFalse();
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeFalse();
Artisan::call('migrate');
expect(Schema::hasColumn('songs', 'imported_from_ccli_at'))->toBeTrue();
expect(Schema::hasColumn('songs', 'ccli_source_url'))->toBeTrue();
});

View file

@ -1,128 +0,0 @@
<?php
use App\Support\CcliLabels;
test('isSectionLabel detects english labels', function (string $label) {
expect(CcliLabels::isSectionLabel($label))->toBeTrue();
})->with([
'Verse 1',
'Chorus',
'Bridge',
'Pre-Chorus',
'Tag',
'Ending',
'Intro',
'Interlude',
'Outro',
'Misc',
]);
test('isSectionLabel detects german labels', function (string $label) {
expect(CcliLabels::isSectionLabel($label))->toBeTrue();
})->with([
'Strophe 1',
'Refrain',
'Brücke',
'Vorrefrain',
'Schluss',
'Zwischenspiel',
]);
test('isSectionLabel detects label variants', function (string $label) {
expect(CcliLabels::isSectionLabel($label))->toBeTrue();
})->with([
'Verse 2a',
'Chorus 1 (Repeat)',
'Bridge x2',
'Verse 2b',
]);
test('isSectionLabel rejects non labels', function (string $text) {
expect(CcliLabels::isSectionLabel($text))->toBeFalse();
})->with([
'Random text',
'We are singing',
'CCLI # 123456',
'© 2024 Publisher',
'',
'Amazing grace how sweet',
]);
test('isMetadataLine detects metadata lines', function (string $line) {
expect(CcliLabels::isMetadataLine($line))->toBeTrue();
})->with([
'© 2020 Hillsong Music',
'CCLI # 4760',
'CCLI-Nr. 1234567',
'ccli.com/license',
'SongSelect Terms',
'All rights reserved',
'Alle Rechte vorbehalten',
]);
test('isMetadataLine rejects normal lines', function (string $line) {
expect(CcliLabels::isMetadataLine($line))->toBeFalse();
})->with([
'Verse 1',
'Amazing grace how sweet the sound',
'Test Song 1',
]);
test('extractCcliId parses song number metadata without license numbers', function (string $line, ?string $expected) {
expect(CcliLabels::extractCcliId($line))->toBe($expected);
})->with([
['CCLI # 4760', '4760'],
['CCLI Song # 1234567', '1234567'],
['CCLI-Nr. 7654321', '7654321'],
['CCLI-Liednummer 9999001', '9999001'],
['CCLI License # 123456', null],
['CCLI-Lizenz # 123456', null],
]);
test('normalizeLabelName converts german labels to english', function (string $input, string $expected) {
expect(CcliLabels::normalizeLabelName($input))->toBe($expected);
})->with([
['Strophe 1', 'Verse 1'],
['Refrain', 'Chorus'],
['Brücke', 'Bridge'],
['Vorrefrain', 'Pre-Chorus'],
['Schluss', 'Ending'],
['Zwischenspiel', 'Interlude'],
]);
test('normalizeLabelName keeps english labels unchanged', function (string $input) {
expect(CcliLabels::normalizeLabelName($input))->toBe($input);
})->with([
'Verse 1',
'Chorus',
'Bridge',
]);
test('normalizeLabelName keeps unknown labels unchanged', function () {
expect(CcliLabels::normalizeLabelName('Foobar'))->toBe('Foobar');
expect(CcliLabels::normalizeLabelName(''))->toBe('');
});
test('parseLabel returns structured data for labels', function (string $label, string $kind, ?string $number, ?string $modifier) {
$result = CcliLabels::parseLabel($label);
expect($result)->not->toBeNull();
expect($result['kind'])->toBe($kind);
expect($result['number'])->toBe($number);
expect($result['modifier'])->toBe($modifier);
})->with([
['Verse 1', 'Verse', '1', null],
['Chorus', 'Chorus', null, null],
['Verse 2a', 'Verse', '2a', null],
['Chorus 1 (Repeat)', 'Chorus', '1', 'Repeat'],
['Strophe 2', 'Strophe', '2', null],
]);
test('parseLabel rejects non labels', function (string $text) {
expect(CcliLabels::parseLabel($text))->toBeNull();
})->with([
'Random text',
'CCLI # 123',
'',
'Amazing grace',
]);

View file

@ -1,77 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('CCLI Bookmarklet', () => {
test('Settings page shows CCLI section with bookmarklet drag link', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
const ccliBtn = page.locator('[data-testid="settings-submenu-ccli"]').first();
await ccliBtn.waitFor({ state: 'visible', timeout: 10000 });
await ccliBtn.click();
await page.waitForTimeout(500);
await expect(page.getByTestId('default-translation-language')).toBeVisible();
await expect(page.getByTestId('ccli-bookmarklet-drag-link')).toBeVisible();
});
test('bookmarklet endpoint returns valid JS', async ({ request }) => {
const res = await request.get('/bookmarklets/ccli-import.js');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('text/javascript');
const body = await res.text();
expect(body).toMatch(/^javascript:/);
expect(body).toContain('import-from-ccli-paste');
expect(body).toContain('songselect.ccli.com');
});
test('bookmarklet redirect page renders with valid base64 prefill', async ({ page }) => {
const payload = JSON.stringify({
title: 'Amazing Grace',
author: 'John Newton',
ccliId: '4760',
sourceUrl: 'https://songselect.ccli.com/Songs/4760',
rawText: 'Amazing Grace\nJohn Newton\n\nVerse 1\nAmazing grace how sweet the sound\n\n© 1779\nCCLI # 4760',
});
const encoded = Buffer.from(payload).toString('base64');
await page.goto(`/songs/import-from-ccli-paste?prefill=${encoded}`);
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('ccli-paste-textarea')).toBeVisible();
});
test('bookmarklet redirect page shows error for invalid base64', async ({ page }) => {
await page.goto('/songs/import-from-ccli-paste?prefill=NOT_VALID_BASE64!!!');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('ccli-prefill-error-message')).toBeVisible();
await expect(page.getByTestId('ccli-paste-textarea')).toBeVisible();
});
test('default language dropdown persists selection', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
const ccliBtn = page.locator('[data-testid="settings-submenu-ccli"]').first();
await ccliBtn.waitFor({ state: 'visible', timeout: 10000 });
await ccliBtn.click();
await page.waitForTimeout(500);
const select = page.getByTestId('default-translation-language');
await select.selectOption('EN');
await page.waitForTimeout(1000);
await page.reload();
await page.waitForLoadState('networkidle');
const ccliBtn2 = page.locator('[data-testid="settings-submenu-ccli"]').first();
await ccliBtn2.waitFor({ state: 'visible', timeout: 10000 });
await ccliBtn2.click();
await page.waitForTimeout(500);
await expect(page.getByTestId('default-translation-language')).toHaveValue('EN');
await page.getByTestId('default-translation-language').selectOption('DE');
await page.waitForTimeout(500);
});
});

View file

@ -1,113 +0,0 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
function loadFixture(name: string): string {
return fs.readFileSync(`tests/Fixtures/ccli/${name}`, 'utf-8');
}
test.describe('CCLI Paste Import - SongDB', () => {
test('SongDB page shows CCLI import buttons', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('open-ccli-paste-dialog-button-songdb')).toBeVisible();
});
test('SongSelect search button opens new tab with prefilled URL', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
// Fill search input
await page.getByTestId('song-list-search-input').fill('amazing grace');
await page.waitForTimeout(300);
// Click SongSelect search button
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByTestId('songselect-search-button-songdb').click(),
]);
await popup.waitForLoadState('domcontentloaded');
expect(popup.url()).toContain('songselect.ccli.com');
expect(popup.url()).toContain('amazing');
await popup.close();
});
test('opens CCLI paste dialog from SongDB', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
await page.getByTestId('open-ccli-paste-dialog-button-songdb').click();
await expect(page.getByTestId('ccli-paste-textarea')).toBeVisible();
await expect(page.getByTestId('ccli-preview-button')).toBeDisabled();
});
test('preview button disabled when textarea empty', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
await page.getByTestId('open-ccli-paste-dialog-button-songdb').click();
await expect(page.getByTestId('ccli-preview-button')).toBeDisabled();
});
test('paste fixture and preview shows metadata', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
await page.getByTestId('open-ccli-paste-dialog-button-songdb').click();
const content = loadFixture('english-only-multi-verse.txt');
await page.getByTestId('ccli-paste-textarea').fill(content);
await expect(page.getByTestId('ccli-preview-button')).toBeEnabled();
await page.getByTestId('ccli-preview-button').click();
await page.waitForTimeout(3000);
const errorMsg = page.getByTestId('ccli-error-message');
const hasError = await errorMsg.isVisible().catch(() => false);
if (hasError) {
test.skip();
return;
}
await expect(page.locator('text=CCLI-Nr')).toBeVisible({ timeout: 5000 });
});
test('duplicate import shows error with edit link', async ({ page }) => {
const content = loadFixture('english-only-multi-verse.txt');
await page.goto('/songs');
await page.waitForLoadState('networkidle');
const cookies = await page.context().cookies();
const xsrf = cookies.find(c => c.name === 'XSRF-TOKEN')?.value ?? '';
const importRes = await page.request.post('/api/songs/import-from-ccli-paste', {
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(xsrf),
},
data: { raw_text: content, mode: 'create' },
});
if (importRes.status() !== 201) {
test.skip();
return;
}
// Now try to import again via UI
await page.goto('/songs');
await page.waitForLoadState('networkidle');
await page.getByTestId('open-ccli-paste-dialog-button-songdb').click();
await page.getByTestId('ccli-paste-textarea').fill(content);
await page.getByTestId('ccli-preview-button').click();
await page.waitForTimeout(2000);
// Click import
const importBtn = page.getByTestId('ccli-import-stay-button');
if (await importBtn.isVisible()) {
await importBtn.click();
await page.waitForTimeout(2000);
await expect(page.getByTestId('ccli-error-message')).toBeVisible();
await expect(page.getByTestId('ccli-existing-song-link')).toBeVisible();
}
});
});

View file

@ -1,61 +0,0 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
test.describe('CCLI Translation Pairing', () => {
test('Translate.vue shows prefill banner when arrived from CCLI pairing', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
const cookies = await page.context().cookies();
const xsrf = cookies.find(c => c.name === 'XSRF-TOKEN')?.value ?? '';
const content = fs.readFileSync('tests/Fixtures/ccli/english-only-multi-verse.txt', 'utf-8');
const importRes = await page.request.post('/api/songs/import-from-ccli-paste', {
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(xsrf),
},
data: { raw_text: content, mode: 'create' },
});
if (importRes.status() !== 201) {
test.skip();
return;
}
const { song_id } = await importRes.json();
await page.goto(`/songs/${song_id}/translate`);
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('translate-source-textarea')).toBeVisible();
});
test('Translate.vue shows no prefill banner without prefill param', async ({ page }) => {
await page.goto('/songs');
await page.waitForLoadState('networkidle');
const cookies = await page.context().cookies();
const xsrf = cookies.find(c => c.name === 'XSRF-TOKEN')?.value ?? '';
const content = fs.readFileSync('tests/Fixtures/ccli/english-only-multi-verse.txt', 'utf-8');
const importRes = await page.request.post('/api/songs/import-from-ccli-paste', {
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(xsrf),
},
data: { raw_text: content, mode: 'create' },
});
if (importRes.status() !== 201) {
test.skip();
return;
}
const { song_id } = await importRes.json();
await page.goto(`/songs/${song_id}/translate`);
await page.waitForLoadState('networkidle');
const banner = page.getByTestId('ccli-prefill-banner');
await expect(banner).not.toBeVisible();
});
});

View file

@ -1,29 +0,0 @@
Test Song 22
Test Artist 22
Verse 1
First verse opens with a hopeful line
Keeping time with the steady rhyme
Verse 2
Second verse moves the story forward
Singing truth with a gentler chord
Verse 3
Third verse carries the middle ground
Bringing peace in a calming sound
Verse 4
Fourth verse points us to the light
Holding fast through the longest night
Verse 5
Fifth verse closes the journey well
With a final line that rings and swells
Chorus
We will sing until the dawn
Faithful hearts will carry on
© 2024 Test Publishing House
CCLI # 9999022

View file

@ -1,50 +0,0 @@
# CCLI Fixture Corpus
Synthetic CCLI SongSelect "View Lyrics" page text fixtures for testing the `CcliPasteParser`.
## Anonymization
- Song titles use `Test Song N`
- Artist names use `Test Artist N`
- CCLI IDs use `9999XXX`
- Lyrics are synthetic but keep realistic SongSelect structure
## Format
Each fixture follows this structure:
1. Song title (first line)
2. Artist name
3. Blank line
4. Section label on its own line
5. Lyric lines
6. Repeated section blocks as needed
7. Footer copyright line starting with `©`
8. Footer `CCLI # NNNNNNN`
## Coverage
| File | Purpose |
|------|---------|
| english-only-multi-verse.txt | Standard EN song with Verse/Chorus/Bridge |
| english-only-single-verse.txt | Minimal song with one verse and one chorus |
| english-german-side-by-side.txt | Alternating EN/DE labels |
| english-french.txt | English labels with French lyrics |
| english-spanish.txt | English labels with Spanish lyrics |
| english-dutch.txt | English labels with Dutch lyrics |
| english-italian.txt | English labels with Italian lyrics |
| german-only.txt | German labels only |
| repeat-marker.txt | Repeat markers like `(Repeat)` and `x2` |
| verse-letter-suffix.txt | Verse suffixes like `2a` and `2b` |
| mixed-german-english-labels.txt | Mixed EN/DE labels |
| missing-copyright.txt | No copyright line |
| missing-year.txt | `©` without year |
| whitespace-edge-cases.txt | Leading/trailing spaces and double blanks |
| umlauts.txt | German umlauts and ß |
| long-bridge.txt | Long bridge with internal blank line |
| pre-chorus.txt | Pre-Chorus sections |
| tag-ending.txt | Tag and Ending sections |
| intro-outro.txt | Intro and Outro sections |
| interlude-misc.txt | Interlude and Misc sections |
| no-translation.txt | English-only import case |
| 5-verses.txt | Stress test with five verses |

View file

@ -1,21 +0,0 @@
Test Song 6
Test Artist 6
Verse 1
In het licht van Uw genade
We find our place and stand secure
Chorus
We praise Your name with lifted hands
Uw trouw blijft voor altijd bestaan
Verse 2
Wanneer de stormen om ons slaan
You calm the sea and carry us through
Chorus
We praise Your name with lifted hands
Uw trouw blijft voor altijd bestaan
© 2024 Test Publishing House
CCLI # 9999006

View file

@ -1,21 +0,0 @@
Test Song 4
Test Artist 4
Verse 1
Nous levons les yeux vers toi Seigneur
Ta paix remplit nos cœurs ce matin
Chorus
We sing Your name with joyful sound
Ton amour nous porte maintenant
Verse 2
Dans le silence, ta voix nous guide
Every step is safe within Your light
Chorus
We sing Your name with joyful sound
Ton amour nous porte maintenant
© 2024 Test Publishing House
CCLI # 9999004

View file

@ -1,21 +0,0 @@
Test Song 3
Test Artist 3
Verse 1
English lyrics line 1 for the opening theme
English lyrics line 2 keeps the melody warm
Strophe 1
Deutsche Liedzeile 1 zum gleichen Gedanken
Deutsche Liedzeile 2 trägt den Refrain vor
Chorus
English chorus line 1 with bright harmony
English chorus line 2 with steady praise
Refrain
Deutscher Refrain Zeile 1 für die Gemeinde
Deutscher Refrain Zeile 2 klingt gemeinsam
© 2024 Test Publishing House
CCLI # 9999003

View file

@ -1,21 +0,0 @@
Test Song 7
Test Artist 7
Verse 1
Nel silenzio ascoltiamo Te
We are renewed by mercy's song
Chorus
We will follow where You lead
Ti adoriamo con verità
Verse 2
Ogni passo porta pace nuova
Your light remains and never fades
Chorus
We will follow where You lead
Ti adoriamo con verità
© 2024 Test Publishing House
CCLI # 9999007

View file

@ -1,26 +0,0 @@
Test Song 1
Test Artist 1
Verse 1
Morning light breaks through the quiet room
Hands are open, hearts awake to You
Grace is rising like the dawn anew
Chorus
We lift our song and breathe Your name
We lift our eyes and sing Your praise
Verse 2
When the night has tried to claim our hope
You remain the anchor of our souls
Bridge
Faith will stand when every fear has passed
Love will last, Your promise holds fast
Chorus
We lift our song and breathe Your name
We lift our eyes and sing Your praise
© 2024 Test Publishing House
CCLI # 9999001

View file

@ -1,13 +0,0 @@
Test Song 2
Test Artist 2
Verse 1
You are near in every breath we take
You are here when shadows start to fade
Chorus
Jesus, You are steady and true
We rest our hope and trust in You
© 2024 Test Publishing House
CCLI # 9999002

View file

@ -1,21 +0,0 @@
Test Song 5
Test Artist 5
Verse 1
Tu gracia llena cada rincón
We stand amazed before Your throne
Chorus
We will declare Your faithful love
Cantaremos hoy tu gran verdad
Verse 2
Cuando el camino no se ve
Your hand is still the one we seek
Chorus
We will declare Your faithful love
Cantaremos hoy tu gran verdad
© 2024 Test Publishing House
CCLI # 9999005

View file

@ -1,21 +0,0 @@
Test Song 8
Test Artist 8
Strophe 1
Du führst uns sanft durch jeden Tag
Dein Licht bleibt nah, auch wenn es dunkel mag
Refrain
Wir singen laut von deiner Gnade
Dein Name bleibt, was immer komme
Strophe 2
Wenn Zweifel kommen, hältst du fest
Dein Friede gibt uns neuen Rest
Brücke
Du bist treu, du bist treu, du bist treu
Unser Herz vertraut dir neu
© 2024 Test Publishing House
CCLI # 9999008

View file

@ -1,20 +0,0 @@
Test Song 20
Test Artist 20
Verse 1
We enter in with open hands
Trusting You will lead the band
Interlude
Instrumental line carries the thought
Misc
Spoken blessing for the room
Quiet hope that fills the tomb
Chorus
We declare Your name aloud
Standing humble, strong, and proud
© 2024 Test Publishing House
CCLI # 9999020

View file

@ -1,20 +0,0 @@
Test Song 19
Test Artist 19
Intro
Instrumental opening for the gathering
Verse 1
You call us close and make us whole
You hold the broken, heal the soul
Chorus
We worship You with all we are
You shine like morning, near and far
Outro
Soft and steady as we go
Your peace remains and overflows
© 2024 Test Publishing House
CCLI # 9999019

View file

@ -1,23 +0,0 @@
Test Song 16
Test Artist 16
Verse 1
We start in silence and receive Your peace
Every burden falls as Your kindness speaks
Bridge
When the night is long, You are still our song
We can trust Your word; it has never failed
There is room for hope in the waiting line
Even now Your light reaches every heart
We will not give up, we will not let go
You are with us here, and You make us whole
Chorus
We lift our hands and sing again
Your faithful love will never end
© 2024 Test Publishing House
CCLI # 9999016

View file

@ -1,12 +0,0 @@
Test Song 12
Test Artist 12
Verse 1
Simple lines for a missing footer test
The parser should still read the body
Chorus
We are here to sing again
Lifted hope in words and men
CCLI # 9999012

View file

@ -1,13 +0,0 @@
Test Song 13
Test Artist 13
Verse 1
This fixture keeps the symbol only
No year is written in the footer line
Chorus
We still can see the copyright mark
And parse the lines in steady order
© Test Publishing House
CCLI # 9999013

View file

@ -1,21 +0,0 @@
Test Song 11
Test Artist 11
Strophe 1
Unsere Herzen stehen auf
Your mercy lifts us higher now
Chorus
We give You praise forevermore
Gemeinsam singen wir empor
Strophe 2
Wenn der Abend still wird hier
We know You stay, You are near
Refrain
Dein Name klingt in jedem Raum
We follow You and hold the sound
© 2024 Test Publishing House
CCLI # 9999011

View file

@ -1,21 +0,0 @@
Test Song 21
Test Artist 21
Verse 1
This song is plain and purely English
It gives a simple import test case
Chorus
We sing the words as written here
No translation layer appears
Verse 2
Every line remains intact
For parser coverage and feedback
Chorus
We sing the words as written here
No translation layer appears
© 2024 Test Publishing House
CCLI # 9999021

View file

@ -1,29 +0,0 @@
Test Song 17
Test Artist 17
Verse 1
We walk by faith and not by sight
You lead us through the quiet night
Pre-Chorus
Now our hearts are turning near
Every promise becomes clear
Chorus
We sing because You are enough
We sing because Your love is strong
Verse 2
When the road bends, You are there
Holding every answered prayer
Pre-Chorus
Now our hearts are turning near
Every promise becomes clear
Chorus
We sing because You are enough
We sing because Your love is strong
© 2024 Test Publishing House
CCLI # 9999017

View file

@ -1,17 +0,0 @@
Test Song 9
Test Artist 9
Verse 1
We remember all Your kindness here
Every step is marked by perfect care
Chorus 1 (Repeat)
Lift the sound again and again
Praise will rise and never end
Bridge x2
Open heaven, pour Your peace
Hold us close and set us free
© 2024 Test Publishing House
CCLI # 9999009

View file

@ -1,21 +0,0 @@
Test Song 18
Test Artist 18
Verse 1
We have seen Your mercy move
We have heard Your steady voice
Chorus
All our days belong to You
We will follow and rejoice
Tag
Holy, holy, holy Lord
We keep singing more and more
Ending
Faithful God, forever stay
Guide our hearts along the way
© 2024 Test Publishing House
CCLI # 9999018

View file

@ -1,17 +0,0 @@
Test Song 15
Test Artist 15
Strophe 1
Ähren im Wind, die Herzen singen
Öffne den Himmel, lass Gnade klingen
Refrain
Über allem steht dein Name klar
Fußspuren führen uns Jahr um Jahr
Brücke
Für immer bist du gut und wahr
Größer ist deine Liebe, wunderbar
© 2024 Test Publishing House
CCLI # 9999015

View file

@ -1,21 +0,0 @@
Test Song 10
Test Artist 10
Verse 1
The morning comes and we arise
Hope is rising in our eyes
Verse 2a
When the road is hard and long
You still teach our hearts to sing
Verse 2b
When the answers do not show
Still Your faithfulness will grow
Chorus
We will trust You all our days
And we will walk in steadfast grace
© 2024 Test Publishing House
CCLI # 9999010

View file

@ -1,14 +0,0 @@
Test Song 14
Test Artist 14
Verse 1
Leading spaces on this lyric line
Trailing spaces on this lyric line
Chorus
This chorus follows after double blanks
Another line with extra spaces
© 2024 Test Publishing House
CCLI # 9999014