Compare commits

...

71 commits

Author SHA1 Message Date
Thorsten Bus ff3484466b fix(songs): resolve seven song/service editing bugs
- CCLI import: group lyrics into 2-line slides (no blank line per line)
- Add-section: searchable label combobox with create-new option
- Service edit: show current global key-visual/background default live
- Assign dialog: prefill+open search, SongSelect link by CCLI nr/name
- "Auf SongSelect suchen" now also opens the CCLI import dialog
- SongDB: mark empty songs "Ohne Inhalt", default-on content filter
- Translation paste: strip section-mark lines so line mapping holds
2026-05-31 21:39:44 +02:00
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00
Thorsten Bus e95abbc1e6 feat(export): sermon sequence + moderator injection 2026-05-31 06:30:25 +02:00
Thorsten Bus e2d6d813de test(e2e): nametag name override fields + namenseinblender settings 2026-05-31 05:03:55 +02:00
Thorsten Bus 4606bb26d6 fix(e2e): correct auth path + strict selector for Namenseinblender 2026-05-31 05:01:38 +02:00
Thorsten Bus 078811e959 fix(ui): remove unused usePage import in ServiceImagePanel 2026-05-31 04:42:35 +02:00
Thorsten Bus ec275ec026 docs: keyvisual/background/nametag features 2026-05-31 04:35:09 +02:00
Thorsten Bus c544f1db60 test: full playlist export assertions 2026-05-31 04:32:47 +02:00
Thorsten Bus 45221ced32 test(e2e): image upload + detail page 2026-05-31 04:20:54 +02:00
Thorsten Bus b36ed6e221 feat(ui): name overrides + namenseinblender setting 2026-05-31 03:41:24 +02:00
Thorsten Bus f948b5665c feat(ui): keyvisual/background panels 2026-05-31 03:38:22 +02:00
Thorsten Bus edceebb2f8 feat(service): finalize snapshot + sync protection 2026-05-31 02:33:32 +02:00
Thorsten Bus 929bda2018 feat(export): sermon sequence + moderator injection 2026-05-31 01:58:08 +02:00
Thorsten Bus bb877d16c6 feat(export): nametag slide builder 2026-05-31 00:48:46 +02:00
Thorsten Bus a19c967594 feat(export): keyvisual fallback slides 2026-05-31 00:43:59 +02:00
Thorsten Bus 196657b52b feat(export): background layer on song/sermon slides 2026-05-31 00:37:23 +02:00
Thorsten Bus d2193bb3b2 feat(service): moderator/preacher name resolution 2026-05-31 00:20:33 +02:00
Thorsten Bus b31f21959f feat(service): keyvisual/background upload + scope choice 2026-05-31 00:15:08 +02:00
Thorsten Bus 1ce30b76e3 feat(service): lazy image resolver 2026-05-31 00:08:50 +02:00
Thorsten Bus 38e79553eb feat(settings): namenseinblender macro + default image settings 2026-05-31 00:04:49 +02:00
Thorsten Bus 7de25b7423 docs: add T3 evidence and learnings
Record the verification output and task notes for the service image column work.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:01:51 +02:00
Thorsten Bus 6061e4c4dd feat(service): add image columns and overrides
Enable storage-backed key visuals and background images plus service-specific moderator and preacher name overrides.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:01:24 +02:00
Thorsten Bus 73a523d0e1 feat(images): cover-fit conversion mode 2026-05-30 23:56:05 +02:00
Thorsten Bus 0a345aa3b2 fix(docker): remove Node.js from production stage; add public-build asset sync pattern
- Remove Node.js from production image (was violating Must NOT Have constraint)
- Add 'RUN cp -r /app/public /app/public-build' in build stage after npm run build
- Replace 'npm run build' in boot-container.sh with 'cp -r /app/public-build/*'
- Add chown www-data for SQLite DB file in init-app.sh
- Remove git from production stage package list (not in plan spec)
- Update ENTRYPOINT comment to reflect new asset sync approach
2026-05-11 13:37:58 +02:00
Thorsten Bus e7ad1b3cce chore: commit pint style fix, CCLI-API.md planning doc, and npm lock update 2026-05-11 11:00:50 +02:00
Thorsten Bus f25715a4fc fix(sanctum): add pp-planer.ddev.site to stateful domains for DDEV dev environment 2026-05-11 10:55:26 +02:00
Thorsten Bus 8a2e250f14 fix(ccli): remove empty if block in CcliPasteParser constructor 2026-05-11 10:47:10 +02:00
Thorsten Bus 73a9c18a10 docs(ccli): add CCLI import section to AGENTS.md 2026-05-11 10:37:31 +02:00
Thorsten Bus 03fdfac3d3 test(e2e): add CCLI paste import, bookmarklet, and translation pairing e2e tests 2026-05-11 10:36:44 +02:00
Thorsten Bus f2b10a4cd7 feat(settings): add CCLI section with bookmarklet installer and default language 2026-05-11 10:31:22 +02:00
Thorsten Bus 3ec25bf70b feat(ccli): add bookmarklet redirect import page 2026-05-11 10:29:52 +02:00
Thorsten Bus d77eb6ad1e feat(translate): accept prefilled translation from CCLI pairing 2026-05-11 10:29:52 +02:00
Thorsten Bus b0320fbef5 feat(ccli): integrate CCLI buttons in ArrangementDialog and SongDB Index 2026-05-11 10:29:52 +02:00
Thorsten Bus 3020800acb feat(ccli): add CcliPasteDialog component 2026-05-11 10:26:10 +02:00
Thorsten Bus 35d3298251 feat(ccli): add CcliPasteController endpoints
- POST /api/ccli/preview: parse-only endpoint (no DB writes)
- POST /api/songs/import-from-ccli-paste: 3 modes (create / pair-with-song / assign-to-service-song)
- GET /songs/import-from-ccli-paste: Inertia page with base64 bookmarklet prefill
- Routes guarded by auth:sanctum + throttle:30,1 (API); auth + web stack (web)
- Maps DuplicateCcliSongException to 409 with existing_song_id and edit_url
- Pest tests (10 cases, 63 assertions): preview, all 3 import modes, 409 dup, 422 errors, unauth, prefill happy/error, login redirect
2026-05-11 09:23:11 +02:00
Thorsten Bus cd0a72124d feat(ccli): serve CCLI bookmarklet JS 2026-05-10 19:34:30 +02:00
Thorsten Bus cd44d6289c feat(ccli): add CcliTranslationPairingService
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 19:02:10 +02:00
Thorsten Bus 091e00f255 feat(ccli): add CcliImportService for song upsert
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 18:56:38 +02:00
Thorsten Bus e4e5df912e fix(ccli): parse common CCLI metadata 2026-05-10 18:54:33 +02:00
Thorsten Bus 9412ca71c9 feat(ccli): implement CcliPasteParser parsing logic 2026-05-10 18:49:18 +02:00
Thorsten Bus 55a3ea3df8 feat(ccli): scaffold CcliPasteParser service 2026-05-10 18:42:21 +02:00
Thorsten Bus 85608f774d feat(settings): add default translation language setting 2026-05-10 18:38:14 +02:00
Thorsten Bus 73b7afcc2f feat(songs): track CCLI import metadata on songs table 2026-05-10 18:33:24 +02:00
Thorsten Bus fc2060b926 docs(ccli): add verification evidence 2026-05-10 18:28:27 +02:00
Thorsten Bus 5c590eda9e feat(ccli): add section-label constants and language mapping 2026-05-10 18:28:18 +02:00
Thorsten Bus 02de6b03c0 test(ccli): add fixture corpus for CCLI paste parser
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 18:24:29 +02:00
Thorsten Bus a10068e783 add sisyphus notepad changes 2026-05-04 07:41:39 +02:00
Thorsten Bus eee35722fb fix(export): inject macros for information/moderation/sermon/agenda_item parts
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-04 06:48:17 +02:00
Thorsten Bus 84adf2b6fb fix: add hidden-label warning badge, use null color fallback in ProImportService 2026-05-04 06:41:05 +02:00
Thorsten Bus 41d4bfe2b7 fix: rename song_group_id to label_id in Vue, add data-testid to MacroIcon, fix Pint style 2026-05-04 06:33:04 +02:00
Thorsten Bus 6d83b5f38c feat(service-edit): macro icon + Anpassen/Standard flow on service edit page 2026-05-04 00:37:05 +02:00
Thorsten Bus 444e6704c5 feat(components): add MacroIcon badge component with count and warning indicator 2026-05-04 00:30:28 +02:00
Thorsten Bus c714f30647 refactor(vue): update ArrangementConfigurator and ArrangementDialog to use label_id 2026-05-04 00:28:49 +02:00
Thorsten Bus b88ae3e918 feat(settings): SettingsController passes assignments, macros, labels, collections to Settings page 2026-05-04 00:26:56 +02:00
Thorsten Bus f494a8a0d7 feat(settings): add LabelImport, MacroImport, MacroAssignments, MacroPicker, LabelPicker components 2026-05-04 00:25:57 +02:00
Thorsten Bus c1cb9bf820 feat(settings): restructure Settings.vue into sidebar layout with 4 submenus + AgendaSettings.vue 2026-05-03 23:50:46 +02:00
Thorsten Bus 6ce5b6e018 feat(controllers): add macro/label import + global assignment + service override controllers and routes
- MacroImportController + LabelImportController: POST endpoints accepting uploaded .bin files,
  delegating to MacrosImportService / LabelsImportService and returning import stats / warnings as JSON.
  Generic German 422 error if parser rejects the file.
- MacroAssignmentController: index renders Settings Inertia page with assignments / macros / labels /
  collections / last-import metadata. store/update/destroy/reorder for global MacroAssignment rows.
- ServiceMacroOverrideController: store snapshots all matching global MacroAssignments into
  service-specific rows when a service opts to override; destroy removes both override and
  service-specific assignments. storeAssignment / updateAssignment / destroyAssignment manage the
  per-service rows directly.
- routes/web.php: 12 new named routes inside the auth middleware group; reorder route placed before
  {macroAssignment} parameter route to avoid capture conflict.
- Tests: 19 new Pest tests across 4 feature files (54 assertions). Full suite 376 passed.
2026-05-03 23:17:04 +02:00
Thorsten Bus cef247336e feat(export): use MacroResolutionService in ProExportService for flexible macro injection 2026-05-03 23:08:22 +02:00
Thorsten Bus 81b2a9caf6 feat(services): add LabelsImportService, MacrosImportService, MacroResolutionService 2026-05-03 23:03:32 +02:00
Thorsten Bus bdbf0c65e3 refactor(php): rename SongGroup references throughout controllers/services/tests
Replace all SongGroup/SongArrangementGroup model usages with Label/SongArrangementLabel
after the schema migration to global labels. Updates 12 app files and 11 test files:

- SongService: createDefaultGroups now finds-or-creates global Labels by name
- ArrangementController: validates label_id (labels are global, no song-ownership)
- ProImportService: imports groups as Labels (firstOrCreate by name); does not
  overwrite existing label colors per spec
- ProExportService/SongPdfController/TranslationService/etc: traverse via
  arrangements -> arrangementLabels -> label -> songSlides chain
- All test factories and assertions adapted to label-based schema
2026-05-03 22:55:02 +02:00
Thorsten Bus a1612dc3ef feat(support): add MacroColorConverter utility 2026-05-03 22:31:44 +02:00
Thorsten Bus 846bd12f90 feat(models): add Label/Macro/MacroAssignment/ServiceMacro models and remove SongGroup 2026-05-03 22:27:21 +02:00
Thorsten Bus 2a02f65517 test: update DatabaseSchemaTest and WipeLegacySongDataTest for new schema 2026-05-03 22:21:49 +02:00
Thorsten Bus bf153b2906 feat(db): auto-migrate 4 legacy macro settings to new assignment system 2026-05-03 22:20:07 +02:00
Thorsten Bus 2b27aa50d5 feat(db)!: drop song_groups, introduce label_id on song_slides, add song_arrangement_labels (BREAKING) 2026-05-03 22:20:01 +02:00
Thorsten Bus a65bf3d595 feat(db): add macro_assignments, service macro override tables, and guarded legacy data wipe 2026-05-03 22:16:46 +02:00
Thorsten Bus 09ab4821fc feat(db): create macros, macro_collections, and junction tables 2026-05-03 22:13:28 +02:00
Thorsten Bus 860db0405f docs: record labels migration verification
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-03 22:10:46 +02:00
Thorsten Bus 767e22eac8 feat(db): create labels table for global slide labels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-03 22:10:35 +02:00
Thorsten Bus e489a984eb chore(deps): bump PHP to 8.4 and update propresenter/parser with Macro/Label support
- Raise PHP requirement from ^8.2 to ^8.4 (parser requires 8.4)
- New parser classes available: MacrosFileReader, LabelsFileReader,
  Macro, MacroLibrary, MacroCollection, Label, LabelLibrary
- Add programmatic test fixtures for macros-sample.bin + labels-sample.bin
- Fix ServiceAgendaItemFactory sort_order to auto-increment
2026-05-03 22:07:56 +02:00
Thorsten Bus 599b8635c9 feat(dev): migrate local development from Valet to DDEV
Production Caddy/FPM setup (build/Dockerfile, docker-compose.yml) is untouched -- this only swaps the local dev stack.

- .ddev/config.yaml: PHP 8.4, Node 20, sqlite (db container omitted), libreoffice/ghostscript/poppler/sqlite3 packages, Vite port 5173 via Traefik, post-start hooks bootstrap the app on every `ddev start`.
- .ddev/commands/web/dev: custom `ddev dev` runs queue + pail + vite (mirror of old `composer dev`).
- start_dev.sh / stop_dev.sh: rewritten as DDEV wrappers so devs can onboard without DDEV knowledge; --keep-ddev keeps containers up.
- vite.config.js: HMR over WSS to https://pp-planer.ddev.site:5173.
- playwright + auth.setup.ts: baseURL switched to https://pp-planer.ddev.site.
- .env.example: APP_URL and CHURCHTOOLS_REDIRECT_URI use ddev.site.
- composer: drop laravel/sail (replaced by DDEV).
- package.json: add explicit "name" so host/container lockfiles match (container WORKDIR is /var/www/html, npm would otherwise pick "html" as project name).
- tests/fixtures/propresenter/Test.pro: inline reference fixture; tests no longer depend on a sibling host directory.
- AGENTS.md: docs rewritten for DDEV workflow.
2026-05-03 18:46:48 +02:00
237 changed files with 14340 additions and 1725 deletions

17
.ddev/commands/web/dev Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
## Description: Run Laravel dev workers (queue, log tail, vite) inside the web container.
## Usage: dev
## Example: ddev dev
## ProjectTypes: laravel
set -euo pipefail
cd /var/www/html
exec npx --yes concurrently \
-c "#c4b5fd,#fb7185,#fdba74" \
--names=queue,logs,vite \
--kill-others \
"php artisan queue:listen --tries=1 --timeout=0" \
"php artisan pail --timeout=0" \
"npm run dev"

312
.ddev/config.yaml Normal file
View file

@ -0,0 +1,312 @@
name: pp-planer
type: laravel
docroot: public
php_version: "8.4"
webserver_type: nginx-fpm
xdebug_enabled: false
additional_hostnames: []
additional_fqdns: []
database:
type: mariadb
version: "11.8"
hooks:
post-start:
- composer: install --no-interaction
- exec: test -f .env || cp .env.example .env
- exec: grep -q '^APP_KEY=base64:' .env || php artisan key:generate
- exec: test -f database/database.sqlite || (touch database/database.sqlite && chmod 664 database/database.sqlite)
- exec: php artisan migrate --force
# Drop node_modules+lock if they were installed for a different platform
# (e.g. host was macOS but container is Linux — rollup native binary mismatch).
- exec: '[ ! -d node_modules ] || node -e ''require("rollup/dist/native")'' >/dev/null 2>&1 || rm -rf node_modules package-lock.json'
- exec: test -d node_modules || npm install
- exec: npm run build
omit_containers: [db]
webimage_extra_packages: [libreoffice, libreoffice-l10n-de, ghostscript, poppler-utils, sqlite3]
use_dns_when_possible: true
composer_version: "2"
disable_settings_management: true
web_environment: []
nodejs_version: "20"
corepack_enable: false
web_extra_exposed_ports:
- name: vite
container_port: 5173
http_port: 5172
https_port: 5173
# Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades.
# webimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0, 8.4
# PostgreSQL versions can be 9-18
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart"
# Note that for most people the commands
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
# as leaving Xdebug enabled all the time is a big performance hit.
# xhgui_http_port: "8143"
# xhgui_https_port: "8142"
# The XHGui ports can be changed from the default 8143 and 8142
# Very rarely used
# host_xhgui_port: "8142"
# Can be used to change the host binding port of the XHGui
# application. Rarely used; only when port conflict and
# bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global]
# Default is "xhgui"
# webserver_type: nginx-fpm, apache-fpm, generic
# timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
# using the $TZ environment variable or the /etc/localtime symlink.
# This is the timezone used in the containers and by PHP;
# it can be set to any valid timezone,
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# For example Europe/Dublin or MST7MDT
# composer_root: <relative_path>
# Relative path to the Composer root directory from the project root. This is
# the directory which contains the composer.json and where all Composer related
# commands are executed.
# composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version)
# - stable
# - preview
# - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "24"
# change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
# additional_hostnames:
# - somename
# - someothername
# would provide http and https URLs for "somename.ddev.site"
# and "someothername.ddev.site".
# additional_fqdns:
# - example.com
# - sub1.example.com
# would provide http and https URLs for "example.com" and "sub1.example.com"
# Please take care with this because it can cause great confusion.
# upload_dirs: "custom/upload/dir"
#
# upload_dirs:
# - custom/upload/dir
# - ../private
#
# would set the destination paths for ddev import-files to <docroot>/custom/upload/dir
# When Mutagen is enabled this path is bind-mounted so that all the files
# in the upload_dirs don't have to be synced into Mutagen.
# disable_upload_dirs_warning: false
# If true, turns off the normal warning that says
# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set"
# ddev_version_constraint: ""
# Example:
# ddev_version_constraint: ">= 1.24.8"
# This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats
# working_dir:
# web: /var/www/html
# db: /home
# would set the default working directory for the web and db services.
# These values specify the destination directory for ddev ssh and the
# directory in which commands passed into ddev exec are run.
# omit_containers: [db, ddev-ssh-agent]
# Currently only these containers are supported. Some containers can also be
# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit
# the "db" container, several standard features of DDEV that access the
# database container will be unusable. In the global configuration it is also
# possible to omit ddev-router, but not here.
# performance_mode: "global"
# DDEV offers performance optimization strategies to improve the filesystem
# performance depending on your host system. Should be configured globally.
#
# If set, will override the global config. Possible values are:
# - "global": uses the value from the global config.
# - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project.
#
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook
# host_https_port: "59002"
# The host port binding for https can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.
# host_webserver_port: "59001"
# The host port binding for the ddev-webserver can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.
# host_db_port: "59002"
# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic
# unless explicitly specified.
# mailpit_http_port: "8025"
# mailpit_https_port: "8026"
# The Mailpit ports can be changed from the default 8025 and 8026
# host_mailpit_port: "8025"
# The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true
# If the host has internet access and the domain configured can
# successfully be looked up, DNS will be used for hostname resolution
# instead of editing /etc/hosts
# Defaults to true
# project_tld: ddev.site
# The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard
# share_default_provider: ngrok
# The default share provider to use for "ddev share"
# Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php
# In this case the user must provide all such settings.
# You can inject environment variables into the web container with:
# web_environment:
# - SOMEENV=somevalue
# - SOMEOTHERENV=someothervalue
# no_project_mount: false
# (Experimental) If true, DDEV will not mount the project into the web container;
# the user is responsible for mounting it manually or via a script.
# This is to enable experimentation with alternate file mounting strategies.
# For advanced users only!
# bind_all_interfaces: false
# If true, host ports will be bound on all network interfaces,
# not the localhost interface only. This means that ports
# will be available on the local network if the host firewall
# allows it.
# default_container_timeout: 120
# The default time that DDEV waits for all containers to become ready can be increased from
# the default 120. This helps in importing huge databases, for example.
#web_extra_exposed_ports:
#- name: nodejs
# container_port: 3000
# http_port: 2999
# https_port: 3000
#- name: something
# container_port: 4000
# https_port: 4000
# http_port: 3999
# Allows a set of extra ports to be exposed via ddev-router
# Fill in all three fields even if you dont intend to use the https_port!
# If you dont add https_port, then it defaults to 0 and ddev-router will fail to start.
#
# The port behavior on the ddev-webserver must be arranged separately, for example
# using web_extra_daemons.
# For example, with a web app on port 3000 inside the container, this config would
# expose that web app on https://<project>.ddev.site:9999 and http://<project>.ddev.site:9998
# web_extra_exposed_ports:
# - name: myapp
# container_port: 3000
# http_port: 9998
# https_port: 9999
#web_extra_daemons:
#- name: "http-1"
# command: "/var/www/html/node_modules/.bin/http-server -p 3000"
# directory: /var/www/html
#- name: "http-2"
# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000"
# directory: /var/www/html
# override_config: false
# By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
# and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and
# hooks:
# post-start: []
# or
# web_environment: []
# or
# additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer"
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define
# for them. Example:
#hooks:

View file

@ -2,7 +2,7 @@ APP_NAME="CTS Presenter"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://cts-work.test
APP_URL=https://pp-planer.ddev.site
# Application Locale (German)
APP_LOCALE=de
@ -74,7 +74,7 @@ CTS_API_TOKEN=CHANGEME
CHURCHTOOLS_URL=https://CHANGEME.church.tools
CHURCHTOOLS_CLIENT_ID=CHANGEME
CHURCHTOOLS_CLIENT_SECRET=CHANGEME
CHURCHTOOLS_REDIRECT_URI=http://cts-work.test/auth/churchtools/callback
CHURCHTOOLS_REDIRECT_URI=https://pp-planer.ddev.site/auth/churchtools/callback
# File Upload Configuration
# Maximum file size in bytes (default: 100MB)
@ -85,6 +85,7 @@ UPLOAD_TEMP_DIR=/tmp
TEST_CTS_USERNAME=
TEST_CTS_PASSWORD=
# Docker: map FPM worker to host user (run `id -u` and `id -g`)
# Production Docker only: map FPM worker to host user (run `id -u` and `id -g`).
# Not used by DDEV local dev.
WWWUSER=1000
WWWGROUP=1000

1
.gitignore vendored
View file

@ -27,3 +27,4 @@ Thumbs.db
tests/e2e/.auth/
test-results/
.dev.pid
.dev.log

View file

@ -0,0 +1,8 @@
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

@ -0,0 +1,9 @@
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

@ -0,0 +1,9 @@
php artisan test tests/Feature/Migrations/LabelsTableTest.php
PASS Tests\Feature\Migrations\LabelsTableTest
✓ labels table has expected columns 0.40s
✓ labels table enforces unique name 0.01s
✓ labels table allows nullable color 0.01s
Tests: 3 passed (4 assertions)
Duration: 0.54s

View file

@ -0,0 +1,33 @@
migrate:fresh output
Dropping all tables ........................................... 13.01ms DONE
INFO Preparing database.
Creating migration table ....................................... 4.76ms DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ........................... 9.49ms DONE
0001_01_01_000001_create_cache_table ........................... 4.35ms DONE
0001_01_01_000002_create_jobs_table ............................ 4.88ms DONE
2026_03_01_100000_extend_users_table ........................... 4.60ms DONE
2026_03_01_100100_create_services_table ........................ 2.92ms DONE
2026_03_01_100200_create_songs_table ........................... 2.08ms DONE
2026_03_01_100300_create_song_groups_table ..................... 3.09ms DONE
2026_03_01_100400_create_song_slides_table ..................... 5.10ms DONE
2026_03_01_100500_create_song_arrangements_table ............... 3.19ms DONE
2026_03_01_100600_create_song_arrangement_groups_table ......... 3.61ms DONE
2026_03_01_100700_create_service_songs_table ................... 3.25ms DONE
2026_03_01_100800_create_slides_table .......................... 3.68ms DONE
2026_03_01_100900_create_cts_sync_log_table .................... 4.32ms DONE
2026_03_02_100000_create_api_request_logs_table ................ 2.15ms DONE
2026_03_02_121522_add_response_body_to_api_request_logs_table .. 1.31ms DONE
2026_03_02_130249_add_cts_song_id_to_songs_and_service_songs_tables 3.30ms DONE
2026_03_02_140000_add_sort_order_to_slides_table ............... 0.91ms DONE
2026_03_02_200000_create_settings_table ........................ 2.48ms DONE
2026_03_29_100001_create_service_agenda_items_table ............ 3.03ms DONE
2026_03_29_100002_add_service_agenda_item_id_to_slides_table .. 13.03ms DONE
2026_03_29_131045_add_missing_cts_song_id_to_service_songs_table 0.53ms DONE
2026_03_29_131359_add_has_agenda_to_services_table ............. 1.24ms DONE
2026_05_03_100100_create_labels_table .......................... 2.50ms DONE

View file

@ -0,0 +1,19 @@
Task T10 evidence: configured Namenseinblender macro
RED:
- `ddev exec php artisan test tests/Feature/NameTagSlideBuilderTest.php`
- Failed with `Target class [App\Services\NameTagSlideBuilder] does not exist.` before implementation.
GREEN:
- `ddev exec php artisan test tests/Feature/NameTagSlideBuilderTest.php`
- Result: 3 passed (4 assertions).
Full verification:
- `ddev exec php artisan test`
- Result: 544 passed (2703 assertions).
- `ddev exec ./vendor/bin/pint`
- Result: PASS, 213 files.
Configured macro contract verified by test:
- text: `Anna Müller\nModeration`
- macro keys: `name`, `uuid`, `collectionName`, `collectionUuid`

View file

@ -0,0 +1,13 @@
Task T10 evidence: no Namenseinblender macro configured
Test coverage:
- `build returns null when namenseinblender macro is not configured`
- Verifies `NameTagSlideBuilder::build('Max Mustermann', 'Moderation')` returns `null` when `namenseinblender_macro_name` is missing.
Targeted verification:
- `ddev exec php artisan test tests/Feature/NameTagSlideBuilderTest.php`
- Result: 3 passed (4 assertions).
Full verification:
- `ddev exec php artisan test`
- Result: 544 passed (2703 assertions).

View file

@ -0,0 +1,7 @@
PASS Tests\Feature\FileConversionServiceTest
✓ contain conversion keeps black bars and fullCover false 0.89s
Tests: 1 passed (12 assertions)
Duration: 1.06s

View file

@ -0,0 +1,16 @@
PASS Tests\Feature\FileConversionServiceTest
✓ cover conversion fills 1920x1080 without black bars 0.71s
✓ contain conversion keeps black bars and fullCover false 0.16s
✓ cover conversion upscales small sources with German quality warning 0.16s
PASS Tests\Feature\FileConversionTest
✓ convert image creates 1920x1080 jpg with black bars and thumbnail 0.13s
✓ small image is upscaled with at most two black bars 0.14s
✓ exact 16:9 image has no black bars 0.26s
✓ small 16:9 image is upscaled without black bars 0.17s
✓ portrait image gets pillarbox bars on left and right 0.21s
Tests: 8 passed (67 assertions)
Duration: 2.11s

View file

@ -0,0 +1,7 @@
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

@ -0,0 +1,6 @@
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

@ -0,0 +1,6 @@
Task T3 evidence
- `ddev exec php artisan test --filter=ServiceImageColumns` ✅
- Result: 4 passed, 11 assertions
- Migration: `2026_05_10_115900_add_image_fields_to_services_table.php`
- `ddev exec php artisan test` ✅ 510 passed

View file

@ -0,0 +1,16 @@
Task T5 fallthrough evidence
Covered fallback cases in tests/Feature/ServiceImageResolverTest.php:
- Service key visual exists in public storage -> returns service filename.
- Service key visual is null and global key visual exists in public storage -> returns global filename.
- Service key visual and global key visual are null -> returns null.
- Service key visual references a missing file, while global key visual exists -> skips missing service file and returns global filename.
- Background resolver covers the same three branches: service file, global fallback, null.
The implementation uses Storage::disk('public')->exists(...) before returning any referenced filename, so nonexistent paths fall through instead of being returned.
Verification:
- Initial RED: ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php failed because App\Services\ServiceImageResolver did not exist.
- GREEN: ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php passed after implementation.
- Full suite: ddev exec php artisan test passed with 519 tests / 2627 assertions.

View file

@ -0,0 +1,15 @@
Task T5 resolution order evidence
ServiceImageResolver implements lazy raw-filename resolution for service imagery:
- keyVisualFor(Service $service): checks $service->key_visual_filename first.
- backgroundFor(Service $service): checks $service->background_filename first.
- If the per-service filename is null or missing on Storage::disk('public'), the resolver checks the matching global Setting key.
- Global keys: current_key_visual, current_background.
- Return contract: raw relative filename such as slides/kv.jpg, never /storage/... URL.
- Pure behavior: no model updates, no Setting writes, no Storage writes.
Verification:
- ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php: PASS (5 tests)
- ddev exec php artisan test: PASS (519 tests, 2627 assertions)
- ddev exec ./vendor/bin/pint app/Services/ServiceImageResolver.php tests/Feature/ServiceImageResolverTest.php: PASS

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,16 @@
Task T7 moderator evidence
Implemented App\Services\NameTagResolver::moderatorFor(Service $service).
Covered behavior:
- Non-empty services.moderator_name wins and is trimmed.
- Without override, first visible agenda item (is_before_event=false) ordered by sort_order then id is used.
- Multiple responsible names are joined with comma-space: "Anna Müller, Tom Klein".
- No override and no visible agenda item returns null.
Verification:
- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist.
- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed.
- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions).
- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php.
- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php.

View file

@ -0,0 +1,28 @@
Task T7 preacher evidence
Implemented App\Services\NameTagResolver::preacherFor(Service $service).
Covered behavior:
- Non-empty services.preacher_name_override wins and is trimmed.
- Without override, services.preacher_name (CTS Predigt role) is returned.
- Without both fields, first visible non-song sermon agenda item is resolved by agenda_sermon_matching patterns via AgendaMatcherService.
- Multiple responsible entries are supported with comma-space joining; empty/missing names return null.
- No override, no CTS preacher name, and no sermon responsible returns null.
Responsible JSON findings:
- ServiceAgendaItem.responsible is cast to array.
- Existing sync test stores associative object shape: {"name":"Max Mustermann"}.
- New tests cover the expected multiple-person list shape: [{"name":"Anna Müller"},{"name":"Tom Klein"}].
- Resolver also supports string entries and firstName/lastName or first_name/last_name fallbacks.
Sermon detection used:
- Prefer configured Setting key agenda_sermon_matching, split by comma, matched with AgendaMatcherService::matchesAny().
- Only visible non-song agenda items are considered (is_before_event=false, service_song_id IS NULL).
- If no setting exists, title/type substring fallback accepts predigt or sermon.
Verification:
- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist.
- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed.
- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions).
- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php.
- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php.

View file

@ -0,0 +1,9 @@
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

@ -0,0 +1,9 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,15 @@
Task T8 evidence — no background / excluded slide types
Verified by `Tests\Feature\ProFileExportTest`:
- `test_export_ohne_background_enthaelt_keine_background_actions`
- Service without resolved background generates successfully
- exported song slides contain zero BACKGROUND media actions
- `test_information_und_moderation_exports_erhalten_keinen_background`
- information bundle gets no BACKGROUND media
- moderation bundle gets no BACKGROUND media
- `test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen`
- final playlist keeps information slides without BACKGROUND media
Verification commands:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php` → 13 passed, 76 assertions
- `ddev exec php artisan test` → 537 passed, 2683 assertions

View file

@ -0,0 +1,20 @@
Task T8 evidence — song/sermon background layer
RED:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
- Failed as expected before implementation:
- song slides had no BACKGROUND media action
- slides table had no `cover_mode` column for full-cover detection
GREEN:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
- 13 passed, 76 assertions
- Verified:
- every song text slide gets BACKGROUND media when ServiceImageResolver resolves a background
- sermon image slides get BACKGROUND media when not full-cover
- full-cover sermon image slides (`cover_mode=true`) skip BACKGROUND media
- final .proplaylist export preserves the same sermon/full-cover behavior
Full suite:
- `ddev exec ./vendor/bin/pint ... && ddev exec php artisan test`
- 537 passed, 2683 assertions

View file

@ -0,0 +1,18 @@
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

@ -0,0 +1,15 @@
Task T9 keyvisual fallback evidence
Scenario: Service has key_visual_filename and a visible non-song agenda item with no uploaded/special slides.
Result:
- PlaylistExportService creates an embedded .pro for the agenda item title.
- The generated .pro contains exactly one image-only slide with background media.
- Background media URL is Storage::disk('public')->path($keyvisual).
- Slide rows are not created; Slide::count() remains unchanged.
Verification:
- RED before implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` failed with `Keine Songs mit Inhalt zum Exportieren gefunden.` for empty non-song agenda item.
- GREEN after implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` → 4 passed, 16 assertions.
- Full suite: `ddev exec php artisan test` → 541 passed, 2699 assertions.
- Pint: `ddev exec ./vendor/bin/pint app/Services/PlaylistExportService.php tests/Feature/KeyVisualFallbackTest.php` → PASS, 2 files.

View file

@ -0,0 +1,16 @@
Task T9 no-keyvisual evidence
Scenario: Service has no keyvisual and a visible non-song agenda item with no uploaded/special slides.
Result:
- Empty non-song agenda item adds no playlist entry and no .pro file.
- Song agenda items still export through the normal song .pro path.
- Uploaded agenda slides still export normally; no keyvisual slide is prepended.
- Song agenda items never receive keyvisual fallback slides.
Verification:
- `tests/Feature/KeyVisualFallbackTest.php` covers:
- no keyvisual → no empty-item .pro entry,
- song item → only song .pro,
- uploaded slides → exactly the uploaded slide presentation and no fallback background.
- Full suite after implementation: 541 passed, 2699 assertions.

View file

@ -0,0 +1,35 @@
# 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

@ -0,0 +1,110 @@
# 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

@ -14,6 +14,7 @@
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
- 2026-05-03: `MacroColorConverter::fromRgba()` muss nur RGB clampen und als uppercase-6-digit Hex ausgeben; `tinker --execute` ist eine schnelle Verifikation fuer solche statischen Helper.
## [2026-03-01] Wave 2 Complete — T8-T13
@ -350,3 +351,5 @@ ### Verification Success Criteria Met
### Next Steps
- Task 2 will likely involve testing OAuth login flow with ChurchTools
- May need to configure CTS_API_TOKEN and CHURCHTOOLS credentials for full testing
- 2026-05-04: `ProBundleExportService` muss fuer non-song `.probundle` Exporte den aktuellen `Service` plus `part_type` bis in `buildBundleFromSlides()` durchreichen; `MacroResolutionService::macrosForSlide()` bekommt fuer Bildfolien `label_id => null`, damit nur all/first/last Positionen greifen.

View file

@ -0,0 +1,110 @@
# Learnings — keyvisual-background-nametag
> Append with `## [TIMESTAMP] Task: {task-id}\n{content}`. NEVER overwrite.
## [2026-05-30] Task: T1 — Parser background-layer
- Parser repo is at `/Users/thorsten/AI/propresenter` (NOT `propresenter-work/php` as plan stated).
- composer.json uses VCS remote `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (NOT a path repo).
- For development: copy changed parser files into `vendor/propresenter/parser/src/` in the app. All future parser changes must also be synced: `cp /Users/thorsten/AI/propresenter/src/CHANGED.php /Users/thorsten/AI/pp-planer/vendor/propresenter/parser/src/CHANGED.php`
- `slideData['background']` contract: `['path' => string, 'format' => 'JPG'|'PNG', 'width' => int, 'height' => int, 'bundleRelative' => bool(opt)]`
- `slideData['imageOnly'] = true` → skips text element entirely.
- Background action is appended BEFORE foreground media in actions array.
- New Slide accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
- Parser commit: `582ef85 feat(parser): background-layer + image-only slide support` in `/Users/thorsten/AI/propresenter` repo.
- All 372 parser tests green + 503 app tests green after vendor sync.
- Evidence: `.sisyphus/evidence/task-1-background-layer.txt`, `task-1-image-only.txt`
## Initial Setup
- Parser pkg at /Users/thorsten/AI/propresenter-work/php (PHPUnit 11, #[Test], ref/ fixtures)
- App at /Users/thorsten/AI/pp-planer (Pest v4 + PHPUnit, in-memory sqlite, ddev commands)
- FileConversionService uses Intervention v3 GD, contain(1920,1080,'000000','center')
- COVER new path: cover(1920,1080) center crop. Thumbnails 320x180 reused.
- Parser GAPS: LAYER_TYPE_BACKGROUND unused; no image-only slide; no text styling needed.
- Settings: Setting::get/set key-value; SettingsController AGENDA_KEYS constant; HandleInertiaRequests shares globals.
- ServiceAgendaItem: is_before_event bool, responsible json, sort_order, service_song_id.
- PlaylistExportService: iterates by sort_order; song items->ProExportService; slide items->addSlidesFromCollection.
## [2026-05-30] Task: T1
- Parser repo was at `/Users/thorsten/AI/propresenter` in this workspace (planned `/Users/thorsten/AI/propresenter-work/php` path was absent).
- Implemented `slideData['background'] = ['path' => string, 'format' => string, 'width' => int, 'height' => int]` to emit `ActionType::ACTION_TYPE_MEDIA` on `LayerType::LAYER_TYPE_BACKGROUND`.
- `slideData['imageOnly'] === true` skips text element generation even if `text` is present, producing background-only/image-only slides with 0 text elements.
- Slides may combine background media plus existing foreground `media`; cue action order is slide action, background media, foreground media so background precedes foreground media without breaking slide-element reads.
- `Slide` read accessors now distinguish foreground media from background media via layer type: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`.
## [2026-05-30] Task: T2
- `FileConversionService::convertImageCover()` now writes JPG slides with `cover(1920, 1080)` and thumbnail generation, returning `filename`, `thumbnail`, `warnings`, `fullCover => true`.
- Existing contain `convertImage()` keeps `contain(1920, 1080, '000000', 'center')` behavior and adds additive metadata `fullCover => false` for downstream export decisions.
- COVER warnings intentionally only include the German upscale/quality warning for sources smaller than 1920×1080, avoiding contain-specific black-bar wording.
- Evidence: `.sisyphus/evidence/task-2-cover-fill.txt`, `.sisyphus/evidence/task-2-contain-regression.txt`.
## [2026-05-30] Task: T3
- Migration filename: `2026_05_10_115900_add_image_fields_to_services_table.php`
- Columns added on `services`: `key_visual_filename`, `background_filename`, `moderator_name`, `preacher_name_override`
- `Service` accessors use `Attribute::get(fn () => $this->... ? '/storage/'.$this->... : null)` for `keyVisualUrl` and `backgroundUrl`
- Added all 4 new fields to `Service::$fillable` so `fill()`/`save()` persists them
- Full suite green after fresh migrate: `ddev exec php artisan test` → 510 passed
## [2026-05-30] Task: T4
- Setting keys added to `SettingsController::AGENDA_KEYS` constant (single source for both index() props and update() Rule::in validation): `current_key_visual`, `current_background`, `namenseinblender_macro_name`, `namenseinblender_macro_uuid`, `namenseinblender_macro_collection_name`, `namenseinblender_macro_collection_uuid`.
- PATCH /settings (route `settings.update`) validates `key` via `Rule::in(self::AGENDA_KEYS)`, `value` nullable string max:500, persists via `Setting::set()`.
- `HandleInertiaRequests::share()` now globally exposes: `namenseinblenderMacro` => {name, uuid, collection_name (default '--MAIN--'), collection_uuid}, plus `currentKeyVisual`, `currentBackground`.
- NOTE: namenseinblenderMacro uses snake_case keys (name/uuid/collection_name/collection_uuid) — differs from existing `macroSettings` which uses camelCase (collectionName/collectionUuid). T10/T14 consumers must use snake_case for namenseinblender.
- Test file `tests/Feature/NamenseinblenderSettingTest.php` (Pest, 4 tests). Inertia prop assertions use `->where('namenseinblenderMacro.name', ...)` dot-path on shared props from any authed page (settings.index).
- 514 app tests green (510 baseline + 4 new). Pint clean.
## [2026-05-31] Task: T5
- `App\Services\ServiceImageResolver` is pure and lazy: it only reads `Service`, `Setting::get(...)`, and `Storage::disk('public')->exists(...)`; it writes nothing.
- Resolution order is per-service filename first, then global setting filename, then `null`; missing files are skipped/fall through at both levels.
- `keyVisualFor()` uses `key_visual_filename` then `current_key_visual`; `backgroundFor()` uses `background_filename` then `current_background`.
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.
## [2026-05-31] Task: T6 — ServiceImageController
- New controller `app/Http/Controllers/ServiceImageController.php`: `storeKeyVisual()` + `storeBackground()` both delegate to private `store($request, $service, $column, $settingKey)`.
- Validation: `file` => required|file|mimes:jpg,jpeg,png|max:20480 (20MB); `scope` => required|Rule::in(['service','default']). German messages via 2nd arg to `$request->validate([...], [...])`.
- Conversion: `app(FileConversionService::class)->convertImageCover($request->file('file'))` → stores `$result['filename']` (slides/{uuid}.jpg) into `key_visual_filename`/`background_filename` via `$service->update([...])`.
- scope=default ALSO calls `Setting::set('current_key_visual'|'current_background', $result['filename'])`. scope=service leaves global Setting untouched.
- Old file NOT deleted on replace (protects finalized snapshots). Verified by test.
- Routes (inside auth group): `POST /services/{service}/key-visual``services.key-visual.store`; `POST /services/{service}/background``services.background.store`.
- Response: `back()->with('success', 'Bild wurde gespeichert.')` (Inertia redirect). Web validation failures redirect 302; tests use `postJson()` to assert the 422 JSON contract.
- GOTCHA: after adding controller, route cache + autoload were stale → `ddev exec composer dump-autoload && ddev exec php artisan optimize:clear`. Also the `use` import edit silently didn't stick first time — verify imports after editing routes/web.php.
- Test file `tests/Feature/ServiceImageControllerTest.php` (6 tests). Helper `makeImageUpload($name,$w,$h)` GD-based (same pattern as SlideControllerTest's makePngUploadForSlide).
- Full suite: 525 passed (519 baseline + 6). Pint clean.
- Evidence: `.sisyphus/evidence/task-6-default-upload.txt`, `task-6-invalid-upload.txt`.
## [2026-05-31] Task: T7 — NameTagResolver
- New service `App\Services\NameTagResolver`: `moderatorFor(Service): ?string`, `preacherFor(Service): ?string`.
- Moderator resolution: trimmed `services.moderator_name` wins; else first visible agenda item (`is_before_event=false`) ordered by `sort_order`, then `id`; responsible names joined by `', '`; no name => `null`.
- Preacher resolution: trimmed `services.preacher_name_override` wins; else trimmed `services.preacher_name`; else first visible non-song sermon agenda item (`service_song_id IS NULL`) responsible names; no name => `null`.
- `responsible` JSON findings: model casts to array; sync test proves associative object shape `{name: 'Max Mustermann'}`; expected multi-person shape is list of objects `[{name: 'Anna Müller'}, {name: 'Tom Klein'}]`. Resolver supports both plus string entries and `firstName`/`lastName` or snake_case fallbacks.
- Sermon detection method: use `Setting::get('agenda_sermon_matching')` comma patterns through `AgendaMatcherService::matchesAny()` (same matcher used by ServiceController/Service finalization). If no setting is configured, fallback checks title/type for `predigt` or `sermon`.
- Tests: `tests/Feature/NameTagResolverTest.php` covers override/fallback/null branches for moderator and preacher. Full suite green: `ddev exec php artisan test` → 532 passed (2659 assertions). Pint clean. Evidence: `.sisyphus/evidence/task-7-moderator.txt`, `.sisyphus/evidence/task-7-preacher.txt`.
## [2026-05-31] Task: T8 — Export background layer
- `ProExportService::buildGroups()` now resolves `ServiceImageResolver::backgroundFor($service)` once per song export and adds `slideData['background']` with `Storage::disk('public')->path(...)`, format JPG, 1920×1080 to every non-full-cover song slide.
- Sermon image exports use the same background contract in `PlaylistExportService` and `ProBundleExportService`; information/moderation slide exports explicitly remain without background.
- Full-cover detection is persisted with new nullable `slides.cover_mode` (`null` legacy/unknown, `false` contain, `true` cover). `SlideController` stores `$result['fullCover']` from image/ZIP conversions; existing contain conversions store `false`.
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.
## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered.
- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file.
- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`.
- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged.
- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`.
## [2026-05-31] Task: T10 — NameTagSlideBuilder
- New pure service `App\Services\NameTagSlideBuilder` builds ephemeral slideData only; it does not persist slides.
- `build()` gates solely on non-empty `Setting::get('namenseinblender_macro_name')`; no configured macro name returns `null` so callers skip nametag slides.
- Text contract is plain parser text string with exactly two lines: `$name."\n".$title`; convenience methods use `Moderation` and `Predigt`.
- Parser-facing macro uses camelCase keys: `name`, `uuid`, `collectionName`, `collectionUuid`; collection name defaults to `--MAIN--` via `Setting::get('namenseinblender_macro_collection_name', '--MAIN--')`.
- Tests: `tests/Feature/NameTagSlideBuilderTest.php` covers no-macro null, configured macro/text shape, and moderator/preacher titles. Full suite green: `ddev exec php artisan test` → 544 passed / 2703 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-10-nametag-macro.txt`, `.sisyphus/evidence/task-10-no-macro.txt`.
## Task 11: Sermon sequence + moderator injection
- Pint auto-removes same-namespace `use App\Services\X` imports (NameTagResolver/NameTagSlideBuilder are already in App\Services); rely on `app(Class::class)` resolution instead of importing.
- Sermon item WITH slides now ALWAYS gets a Keyvisual-Predigt .pro prepended (macro-independent), then Predigername nametag (only if macro set), then sermon slides. This changed KeyVisualFallbackTest expectation from 1 to 2 embedded .pro files.
- Moderator nametag injected as FIRST playlist entry for the first is_before_event=false agenda item (only if macro set).
- PlaylistArchive::getEntries() + entry->getName() is the way to assert playlist ORDER in tests.

View file

@ -0,0 +1,41 @@
# Decisions — macros-and-labels-import
## [2026-05-03] Architectural Decisions
### Schema
- **labels table**: global, unique by name, nullable color, hidden_at (NOT deleted_at)
- **macros table**: unique by uuid (uppercase), hidden_at (NOT deleted_at)
- **macro_assignments**: restrictOnDelete on macro_id and label_id FKs
- **service_macro_overrides**: existence of row = override active; no extra boolean
- **song_arrangement_labels**: replaces song_arrangement_groups; references global label_id
### Macro Assignment Semantics
- `part_type` enum: `information | moderation | sermon | song | agenda_item`
- `position` enum: `all_slides | first_slide | last_slide | by_label`
- `by_label` is valid for ALL part_types (not songs-only) — validated at app level if restriction needed
- Stacking: multiple assignments can fire on same slide — all applied in `order ASC`
- Override wins 100% — no globals bleed through when override exists
### Override Semantics
- "Anpassen" snapshots current globals into `service_macro_assignments` rows
- "Auf Standard zurücksetzen" deletes the override row + cascades service_macro_assignments
- German tooltip: "Erstellt eine Kopie der aktuellen globalen Zuweisungen für diesen Gottesdienst. Spätere Änderungen an den globalen Zuweisungen wirken sich auf diesen Gottesdienst NICHT mehr aus."
### Data Migration
- Destructive: `up()` deletes songs, song_groups, song_slides, song_arrangements, song_arrangement_groups
- `down()` throws RuntimeException (irreversible)
- Guard: `if (!Schema::hasTable('song_groups') || !DB::table('song_groups')->exists()) return;`
- Old 4 macro settings keys → migrated to global assignment if all present; then deleted
### Label Color Priority
1. Labels file import → always sets/overwrites color
2. .pro song import → only sets color on CREATE (new label); existing color preserved
3. UI → read-only (no manual edit)
### Current Migration Scope
- `labels` migration only defines the schema; no model or business logic belongs in this task
- Use `hidden_at` instead of `deleted_at` to align with soft-hide semantics
### Macros Tables Task
- Keep all three tables in one migration file so the schema lands together and the junction FKs resolve cleanly during `migrate:fresh`
- Store `last_imported_filename` as nullable text metadata on `macros`; no separate import log table for this task

View file

@ -0,0 +1,145 @@
# Learnings — macros-and-labels-import
## [2026-05-03] Session ses_210cd1557ffeGs4SEGrt7hnvyS — Plan Created
### Parser Library
- Source at `/Users/thorsten/AI/propresenter/src/` (NOT `/Users/thorsten/AI/propresenter-work/php/` per stale AGENTS.md)
- VCS repo: `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (dev-master)
- New classes (NOT yet in vendor/): `MacrosFileReader`, `LabelsFileReader`, `Macro`, `MacroLibrary`, `MacroCollection`, `Label`, `LabelLibrary`
- `MacrosFileReader::read(string $filePath): MacroLibrary` — raw protobuf binary, no extension
- `LabelsFileReader::read(string $filePath): LabelLibrary` — same
- `Label::getName()` returns protobuf `text` field — name is the identity (no UUID for labels)
- `Macro::getColor()` returns `?array{r,g,b,a}` floats 0..1 — need `MacroColorConverter` to get hex
- `Label::getColorHex()` already returns `#RRGGBB` — mirror its formula for macros
- **PHP 8.4 required** by parser. App currently requires `^8.2` — BLOCKER for T0.1
### DB Schema Key Facts
- `slides.type` enum is `[information, moderation, sermon]` ONLY — no `agenda_item`
- `agenda_item` part_type = slide where `service_agenda_item_id IS NOT NULL` at runtime
- `song_groups.color` is NOT NULLABLE (migration says so) — new `labels.color` IS nullable
- `service_songs.song_id` is `cascadeOnDelete` — wiping `songs` auto-cascades to `service_songs`
### Export Flow
- `ProExportService::buildGroups()` lines 38-69 — macro injection point
- `ProExportService::buildMacroData()` lines 71-86 — reads 4 legacy settings keys
- Currently injects macro ONLY when group name is "COPYRIGHT" (case-insensitive)
- `ProImportService::import(UploadedFile $file): array` — method signature (NOT `importFromFile`)
### Settings Pattern
- `Setting::get($key, $default)` / `Setting::set($key, $value)` — simple key/value
- `settings` table: `key UNIQUE, value TEXT`
### Critical Decisions
- song_groups → labels: global table, "drop all data" migration (no backwards compat)
- Hybrid macro scope: global defaults in Settings; per-(service, part_type) override via "Anpassen"
- Override = snapshot of globals at creation time; future global changes don't propagate
- Stacking: all matching assignments fire, ordered by `macro_assignments.order ASC`
- Hidden macros/labels: skip at export, warning badge in editor
- Label colors: read-only in UI; Labels file import is sole authority; .pro auto-discovery only sets color on CREATE
- FK rules: `restrictOnDelete` on macro/label refs (use `hidden_at`); `cascadeOnDelete` on service-scoped rows
### Migration/Test Notes
- `tests/Pest.php` already applies `RefreshDatabase` to all `Feature` tests; no extra setup needed for `Feature/Migrations`
- SQLite unique constraint errors can be asserted with `->toThrow(\Exception::class)` in migration tests
- `macro_collection_macros` can safely use a reserved-ish `order` column name in SQLite/Laravel migrations; the schema + foreign keys passed `migrate:fresh`
- `foreignId()->constrained()->cascadeOnDelete()` correctly cascades through the junction table under the current sqlite test setup
## T2.1 — Models + Factories (label-based schema)
- **All new model conventions match house style**: `$fillable` array, `casts()` method (not `$casts` property), typed return types on relations.
- **`hidden_at` semantics, NOT SoftDeletes**: `Label` and `Macro` use `hidden_at` timestamp + `isHidden()` helper; SoftDeletes deliberately not used.
- **`MacroCollection` pivot ordering**: `belongsToMany(Macro::class, 'macro_collection_macros')->withPivot('order')->orderBy('macro_collection_macros.order')` — must qualify the column with the pivot table name to avoid SQLite ambiguous column errors.
- **`ServiceMacroOverride::assignments()` uses composite-key relation**: HasMany on `service_id` with explicit `where('part_type', $this->part_type)` filter (Eloquent has no native composite-FK support).
- **`SongArrangement::arrangementLabels()` ordered**: `hasMany(SongArrangementLabel::class)->orderBy('order')` so consumers see labels in the correct slide order without re-sorting.
- **`SongArrangementLabelFactory`** uses `Label::factory()` and `SongArrangement::factory()` directly — both have HasFactory trait.
- **Test gating**: After T2.1 alone, 270/328 tests pass. The remaining 58 failures are all in `app/Services/SongService.php`, `app/Services/ProImportService.php`, and the test files that exercise those services; T4.4 owns those updates.
- **`DatabaseSchemaTest`** passes cleanly (3 tests / 31 assertions): all expected tables exist, dropped tables gone, all factories produce valid rows.
## T4.4 — PHP rename audit (2026-05-03)
After Wave 2's schema migration (`song_groups` → `labels`, `song_arrangement_groups``song_arrangement_labels`), the rename-audit cleanup turned out to span **far more files** than the plan listed (12 app files + 11 test files vs 7 listed). Key findings:
- `Song::groups()` relation was completely removed; many call sites needed adaptation, not just rename. New pattern: traverse `Song -> arrangements -> arrangementLabels -> label -> songSlides` for content.
- `song_slides` table only has `label_id` (no `song_id` either) — slides are now globally owned by labels. Tests that previously did `$verse = $song->groups()->create(...)` need to find/create a global Label and link it via `SongArrangementLabel`.
- Helper functions defined at file level in Pest tests work cleanly: `function makeSongWithDefaultArrangement(): array { ... }` keeps test setup DRY.
- Fixture `Test.pro` has 4 groups but only 3 are referenced in any arrangement — assertion needs to count `Label::count()` (post-import) to verify "all 4 groups created", not arrangement labels.
- `MacroColorConverter::fromRgba()` (assoc-keyed `r,g,b`) replaces the old `ProImportService::rgbaToHex()` for label color conversion in importer; the legacy hex helpers were preserved because `ProFileGenerator::colorFromArray` uses numeric-indexed RGBA.
- Removing the "groups must belong to this song" check in `ArrangementController::update` is correct since labels are global; `exists:labels,id` validation is sufficient.
## Wave 2 — T2.3, T2.4, T2.5 (services)
### LabelsImportService
- Case-insensitive name lookup via `whereRaw('LOWER(name) = ?', [strtolower($name)])`
- Always updates color on existing labels (additive policy, never disables)
- Skips labels with empty names
- Stores metadata in `settings` table: `labels_last_imported_at`, `labels_last_imported_filename`
### MacrosImportService
- UUID is normalized to UPPER before storage (matches parser convention)
- Macros not in file get `hidden_at = now()` (soft-disable, not delete)
- Re-import re-enables a previously hidden macro by setting `hidden_at = null`
- Tracks `wasHidden` to differentiate `reEnabled` vs `updated` counts
- Collection sync: detach all → attach with order index from parser
- Warnings: any MacroAssignment whose macro is currently hidden
### MacroResolutionService
- Override-vs-defaults: `ServiceMacroOverride` existence check decides whether to use service-specific or global assignments
- Hidden macros and hidden labels (for `by_label`) are filtered via Collection->reject()
- `macrosForSlide` uses match() expression for position semantics
- Default collection fallback: `--MAIN--` with UUID `8D02FC57-83F8-4042-9B90-81C229728426`
### Pint quirk
- DTO classes with empty body need `{}` on same line as constructor closing paren — `single_line_empty_body` rule.
### Test patterns
- Pest auto-applies `RefreshDatabase` via `tests/Pest.php` for all Feature tests, but explicit `uses(RefreshDatabase::class)` is harmless and matches spec.
- All 354 tests pass (was 334 before Wave 2.3-2.5).
## T2.7 ProExportService MacroResolutionService
- ProPresenter parser package currently consumes only `$slideData['macro']` in `ProFileGenerator::buildCue()`; no `$slideData['macros']` stacking support exists. `Slide::setMacro()` also updates/replaces the first macro action.
- `ProExportService` now keeps song downloads backward-compatible by accepting optional `?Service`; exports without service context intentionally emit no macros.
- Playlist/bundle service exports must pass the active `Service` into `generateProFile()` / `generateParserSong()` so `MacroResolutionService::macrosForSlide()` can resolve global or service-specific assignments.
- Full verification for T2.7: `ddev exec php artisan test` passed with 357 tests / 1706 assertions; evidence in `.sisyphus/evidence/task-2.7-pest.txt`.
## T2.8 Controllers + Routes (2026-05-03)
- **4 thin controllers, all JSON responses for mutations** (Inertia only on `MacroAssignmentController::index`).
- **Validation via inline `$request->validate()`** with `in:` lists for `part_type` (information, moderation, sermon, song, agenda_item) and `position` (all_slides, first_slide, last_slide, by_label).
- **Route ordering matters**: `/settings/macro-assignments/reorder` MUST be registered BEFORE `/settings/macro-assignments/{macroAssignment}`, else `reorder` is captured as the model parameter.
- **Route-model binding works automatically** for both `{macroAssignment}` and `{serviceMacroAssignment}` — Laravel resolves snake_case → StudlyCase → Eloquent model.
- **Unused `$service` parameter on update/destroyAssignment** is intentional: route-model binding requires it in the signature even if the assignment binding alone does the work.
- **Generic 422 message** for parser failures hides internal exception details from users; all messages German Du-form.
- **Test fixtures `tests/fixtures/macros-sample.bin` & `labels-sample.bin`** work with `new UploadedFile(path, name, null, null, true)` (5th arg `$test=true` keeps the file at original path so `getPathname()` returns the fixture).
- **`UploadedFile::fake()->create('x.bin', 1)`** generates a 1KB empty file that fails parser parsing → triggers the controller's catch block → 422 JSON.
- **Auth tests use plain `post()` (form-data) → `assertRedirect(route('login'))`**; JSON requests would return 401, but session-based auth redirects.
- **Final test count: 376 (was 357) → +19 new tests / +54 assertions.**
## T4.2: Service Edit Macro Panel
- `ServiceController::edit()` now passes `macros_per_part` keyed by part_type (information, moderation, sermon, song, agenda_item).
- Each entry: `count`, `is_overridden`, `has_warning`, `assignments[]` (with macro_id/name/color/hidden, position, label_id/name).
- Uses `MacroResolutionService::resolveAssignmentsForPart()` (already filters hidden macros + by_label with hidden labels). `has_warning` checks raw flags before resolver filters them — but since resolver already filters, `has_warning` will normally be false. Acceptable for badge UI.
- `ServiceMacroOverride::where(...)->exists()` checks override status per part.
- `ServicePartMacroPanel.vue` is positioned `absolute right-0 top-8 z-50` — wrapper must be `class="relative"`.
- Edit.vue page only has 2 visible block headers (Ablauf and Information). Placed agenda_item/moderation/sermon/song MacroIcons in the Ablauf header row; placed information MacroIcon in the Information block header.
- MacroIcon renders only when `count > 0`, so empty parts gracefully hide their badge.
- Routes used: `services.macro-overrides.store` (POST + body `{part_type}`), `services.macro-overrides.destroy` (DELETE + body). XSRF token sourced from `XSRF-TOKEN` cookie (URL-decoded).
## Final Verification F4 (2026-05-04)
- Scope-fidelity verification passed: `Label`/`Macro` use `hidden_at` (no SoftDeletes), label imports are additive with color overwrite, missing macros are hidden via `hidden_at`, `MacroResolutionService` resolves override/default assignments and filters hidden macros/labels, `ProExportService` injects `MacroResolutionService` with no legacy `buildMacroData()`, and `SettingsController` only exposes the four `AGENDA_KEYS`.
- Forbidden-pattern grep suite returned no output for label CRUD, macro action runner/editor patterns, TS suppressions, Vue console logs, bulk operations, label/macro drag reorder, and export caching.
## [2026-05-04] Session follow-up — hidden label badge + nullable import color
- `MacroAssignments.vue` should mirror hidden-macro warnings for `by_label` rows with `a.label?.hidden_at`, using a red badge and `data-testid="warning-hidden-label"`.
- `ProImportService` must keep new label colors nullable: `MacroColorConverter::fromRgba($color)` should flow through unchanged so missing `.pro` colors become `NULL`, not `#808080`.
## 2026-05-04 F1 final compliance audit
- Final verification commands passed: no Vue song_group_id references, MacroIcon and hidden-label test IDs present, ProImportService #808080 fallback removed, required macro/label deliverables and schema present, Label/Macro use hidden_at without SoftDeletes, routes present, ProBundleExportService resolves macros for information/moderation/sermon and agenda_item exports.
- MacroResolutionService supports part types dynamically via part_type string; grep for literal part names can be empty without indicating non-support.
## 2026-05-04 F4 scope fidelity check
- Must-NOT grep suite found one historical toast pattern in pre-existing service/song Vue files; no macro/label feature-specific forbidden patterns were found (no SoftDeletes/deleted_at, drag UI, runner/preview, bulk ops, optimistic markers, collection assignment, export caching, agenda_item slide enum, TS suppressions, or console.log).
- Required macro/label evidence present: all 5 part types, position enum including by_label, hidden_at semantics, explicit restrict/cascade FKs, stacking resolver (`filter` → `map``values``all`), bundle export macro injection, German UI labels, and test IDs on macro/label picker/icon components.
- Unaccounted grep output for `ArrangementConfigurator.vue`, `ArrangementDialog.vue`, and `SongEditModal.vue` is explained by planned SongGroup → Label rename work, not unrelated scope creep.

194
AGENTS.md
View file

@ -98,6 +98,145 @@ ## 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.
---
## KeyVisual & Background
Each service export can carry a key-visual image (shown as a standalone fallback slide) and a background image (rendered as a media layer behind every song/sermon slide).
Resolution is lazy and happens at export time:
1. Per-service column (`key_visual_filename` / `background_filename` on the `services` table)
2. Global default from Settings (`current_key_visual` / `current_background`)
3. None (slide omitted / no background layer)
When a service is finalized, the resolved filenames are snapshotted into the per-service columns so the export is stable even if the global default changes later.
### Export naming contract (portable bundles)
On export the key-visual and background images are **embedded into the archive** under fixed names and referenced **bundle-relative** inside the `.pro` file (never by absolute path), so exports are portable to the presenter PC:
| Image | Embedded filename + `.pro` reference |
|-------|--------------------------------------|
| Key-visual | `KEY_VISUAL.jpg` |
| Background | `BACKGROUND.jpg` |
The fixed names are defined as `ServiceImageResolver::KEY_VISUAL_EXPORT_NAME` / `ServiceImageResolver::BACKGROUND_EXPORT_NAME`. The `slideData['background']` array carries `'path' => '<FIXED_NAME>'` with `'bundleRelative' => true`; the image bytes are added to the archive's embedded/media files under that same name (deduplicated per archive). Applies to `.proplaylist` (`PlaylistExportService`) and `.probundle` (`ProBundleExportService`). The bare single-song `.pro` download has no service context and carries no background.
### Key files
| File | Purpose |
|------|---------|
| `app/Services/ServiceImageResolver.php` | Lazy resolution: per-service column → global Setting → null |
| `app/Http/Controllers/ServiceImageController.php` | `POST /services/{service}/key-visual` + `POST /services/{service}/background`; scope dialog ("Nur für diesen Service" / "Als Standard setzen") |
| `app/Services/FileConversionService.php` | Added `convertImageCover()` for COVER-mode 1920×1080 conversion |
| `app/Services/PlaylistExportService.php` | Injects keyvisual fallback slides and sermon sequence (keyvisual → nametag → slides) |
| `app/Services/ProExportService.php` | Adds background media layer on song/sermon slides |
| `resources/js/Components/ServiceImagePanel.vue` | Upload panel with scope dialog; used on the service Edit page |
| `resources/js/Pages/Services/Edit.vue` | Image panels rendered at the top of the edit form |
| `resources/js/Pages/Settings.vue` | Global default key-visual and background fields |
### Settings keys
| Key | Purpose |
|-----|---------|
| `current_key_visual` | Global default key-visual filename |
| `current_background` | Global default background filename |
### Routes
| Method | Route | Name |
|--------|-------|------|
| POST | `/services/{service}/key-visual` | `services.key-visual.store` |
| POST | `/services/{service}/background` | `services.background.store` |
### Parser package changes (commit `582ef85`)
- New `slideData['background']` contract: background-layer media action on any slide
- New `slideData['imageOnly']` flag: image-only slide (no text layer)
- New Slide read accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
---
## NameTag (Namenseinblender)
A name-tag slide is injected into the sermon sequence (between the key-visual and the sermon slides) to display the moderator and/or preacher name on screen.
The slide is only generated when the Namenseinblender macro is fully configured in Settings. It renders plain white text and optionally triggers a ProPresenter macro.
### Name resolution order
1. `moderator_name` / `preacher_name_override` columns on the `services` table (manual override set via Edit form)
2. Responsible person from the CTS `responsible` JSON field matching the configured role
3. None (slide omitted)
### Key files
| File | Purpose |
|------|---------|
| `app/Services/NameTagResolver.php` | Resolves moderator/preacher name: responsible JSON → CTS role → manual override |
| `app/Services/NameTagSlideBuilder.php` | Builds `slideData` for the nametag slide (plain white text + optional macro) |
| `app/Http/Controllers/ServiceController.php` | `PATCH /services/{service}/name-overrides` — saves manual name overrides |
| `resources/js/Pages/Services/Edit.vue` | Name override input fields in the edit form |
| `resources/js/Pages/Settings.vue` | Namenseinblender submenu with macro name/UUID/collection fields |
### Settings keys
| Key | Purpose |
|-----|---------|
| `namenseinblender_macro_name` | ProPresenter macro name |
| `namenseinblender_macro_uuid` | ProPresenter macro UUID |
| `namenseinblender_macro_collection_name` | Macro collection name |
| `namenseinblender_macro_collection_uuid` | Macro collection UUID |
### Routes
| Method | Route | Name |
|--------|-------|------|
| PATCH | `/services/{service}/name-overrides` | `services.name-overrides.update` |
---
## Repository Structure
Two git repositories, both local (no remote):
@ -105,54 +244,63 @@ ## Repository Structure
| Repo | Path | Branch | Purpose |
|------|------|--------|---------|
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
| **propresenter-work** | `/Users/thorsten/AI/propresenter-work/php` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
The parser is linked via `composer.json` path repository: `"url": "../propresenter-work/php"`.
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
## Build, Test, Lint Commands
### pp-planer (Laravel App)
Local dev runs in **DDEV** (Docker). Site URL: `https://pp-planer.ddev.site`.
```bash
# First-time setup
composer setup # install, .env, key:generate, migrate, npm install, npm build
# Easy onboarding wrappers (no DDEV knowledge required):
./start_dev.sh # ddev start + spawns dev workers in background
./stop_dev.sh # stops workers and DDEV (use --keep-ddev to leave DDEV running)
# Dev server (Laravel + Vite + Queue + Logs)
composer dev
# Or use DDEV directly:
ddev start # composer install, key:generate, migrate, npm install, npm run build
ddev dev # queue + log tail + vite HMR (foreground)
ddev stop # stop the project
# Build frontend
npm run build
# Open a shell inside the web container
ddev ssh
# Run ALL PHP tests (206 tests, clears config cache first)
composer test
php artisan test
# Frontend
ddev npm run build
ddev npm run dev
# Run ALL PHP tests (clears config cache first)
ddev composer test
ddev exec php artisan test
# Single test file
php artisan test tests/Feature/ServiceControllerTest.php
ddev exec php artisan test tests/Feature/ServiceControllerTest.php
# Single test method
php artisan test --filter=test_service_kann_abgeschlossen_werden
ddev exec php artisan test --filter=test_service_kann_abgeschlossen_werden
# Test suite
php artisan test --testsuite=Feature
php artisan test --testsuite=Unit
ddev exec php artisan test --testsuite=Feature
ddev exec php artisan test --testsuite=Unit
# PHP formatting (Laravel Pint, default preset — no pint.json)
./vendor/bin/pint
./vendor/bin/pint --test # check only
ddev exec ./vendor/bin/pint
ddev exec ./vendor/bin/pint --test # check only
# E2E tests (requires dev server at http://pp-planer.test)
# E2E tests (requires `ddev start` running; baseURL is https://pp-planer.ddev.site)
npx playwright test
npx playwright test tests/e2e/service-list.spec.ts
# Migrations
php artisan migrate
ddev exec php artisan migrate
```
### propresenter-work (Parser Module)
### propresenter (Parser Module)
```bash
cd /Users/thorsten/AI/propresenter-work/php
cd /Users/thorsten/AI/propresenter
# Run all tests (230 tests)
./vendor/bin/phpunit
@ -177,7 +325,7 @@ ## Architecture
tests/Feature/ # Pest v4 / PHPUnit feature tests
tests/e2e/ # Playwright browser tests (TypeScript)
propresenter-work/php/
propresenter/
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
tests/ # PHPUnit 11 tests with #[Test] attributes
ref/ # .pro fixture files for testing
@ -241,7 +389,7 @@ ### ProPresenter Parser Tests (PHPUnit 11)
### E2E Tests (Playwright)
- TypeScript in `tests/e2e/`, baseURL `http://pp-planer.test`
- TypeScript in `tests/e2e/`, baseURL `https://pp-planer.ddev.site` (HTTPS, `ignoreHTTPSErrors: true` set in `playwright.config.ts`)
- Auth via `auth.setup.ts` (XSRF token + `/dev-login` endpoint, saves state to `.auth/user.json`)
- Selectors: `page.getByTestId('...')`, `page.getByText('...')`, `page.getByRole('...')`
- German text assertions: `await expect(page.getByText('Mit ChurchTools anmelden')).toBeVisible()`

429
CCLI-API.md Normal file
View file

@ -0,0 +1,429 @@
# 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

@ -0,0 +1,15 @@
<?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

@ -2,8 +2,10 @@
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongSection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -23,17 +25,24 @@ public function store(Request $request, Song $song): RedirectResponse
'is_default' => false,
]);
$groups = $song->groups()->orderBy('order')->get();
$rows = $groups->map(fn ($group, $index) => [
$defaultArr = $song->arrangements()->where('is_default', true)->first();
if ($defaultArr === null) {
return;
}
$arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get();
$rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'song_section_id' => $arrangementSection->song_section_id,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
])->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
$arrangement->arrangementSections()->insert($rows);
}
});
@ -47,14 +56,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
]);
DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementGroups');
$arrangement->loadMissing('arrangementSections');
$clone = $arrangement->song->arrangements()->create([
'name' => $data['name'],
'is_default' => false,
]);
$this->cloneGroups($arrangement, $clone);
$this->cloneArrangementLabels($arrangement, $clone);
});
return back()->with('success', 'Arrangement wurde geklont.');
@ -64,33 +73,23 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
{
$data = $request->validate([
'groups' => ['array'],
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
'groups.*.section_id' => ['nullable', 'integer', 'exists:song_sections,id'],
'groups.*.label_id' => ['nullable', 'integer', 'exists:labels,id'],
'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
$uniqueGroupIds = $groupIds->unique()->values();
$sectionIds = $this->sectionIdsForGroups($arrangement, $data['groups'] ?? []);
$validGroupIds = $arrangement->song->groups()
->whereIn('id', $uniqueGroupIds)
->pluck('id');
DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
$arrangement->arrangementSections()->delete();
if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
throw ValidationException::withMessages([
'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
]);
}
DB::transaction(function () use ($arrangement, $groupIds, $data): void {
$arrangement->arrangementGroups()->delete();
$rows = $groupIds
$rows = $sectionIds
->values()
->map(fn (int $songGroupId, int $index) => [
->map(fn (int $sectionId, int $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroupId,
'song_section_id' => $sectionId,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
@ -98,14 +97,19 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
$arrangement->arrangementSections()->insert($rows);
}
if (! empty($data['group_colors'])) {
foreach ($data['group_colors'] as $groupId => $color) {
$arrangement->song->groups()
->whereKey((int) $groupId)
->update(['color' => $color]);
$sections = SongSection::whereIn('id', collect(array_keys($data['group_colors']))->map(fn ($id) => (int) $id))
->get()
->keyBy('id');
foreach ($data['group_colors'] as $id => $color) {
$section = $sections->get((int) $id);
$labelId = $section?->label_id ?? (int) $id;
Label::whereKey($labelId)->update(['color' => $color]);
}
}
});
@ -136,28 +140,62 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
return back()->with('success', 'Arrangement wurde gelöscht.');
}
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void
{
if ($source === null) {
return;
}
$groups = $source->arrangementGroups
$arrangementSections = $source->arrangementSections
->sortBy('order')
->values();
$rows = $groups
->map(fn ($arrangementGroup) => [
$rows = $arrangementSections
->map(fn ($arrangementSection) => [
'song_arrangement_id' => $target->id,
'song_group_id' => $arrangementGroup->song_group_id,
'order' => $arrangementGroup->order,
'song_section_id' => $arrangementSection->song_section_id,
'order' => $arrangementSection->order,
'created_at' => now(),
'updated_at' => now(),
])
->all();
if ($rows !== []) {
$target->arrangementGroups()->insert($rows);
$target->arrangementSections()->insert($rows);
}
}
private function sectionIdsForGroups(SongArrangement $arrangement, array $groups): \Illuminate\Support\Collection
{
$songId = $arrangement->song_id;
$sectionIds = collect($groups)->map(function (array $group) use ($songId) {
if (isset($group['section_id'])) {
$section = SongSection::find((int) $group['section_id']);
if ($section === null || (int) $section->song_id !== (int) $songId) {
throw ValidationException::withMessages([
'groups' => 'Diese Sektion gehört nicht zu diesem Song.',
]);
}
return $section->id;
}
if (isset($group['label_id'])) {
$section = SongSection::where('song_id', $songId)
->where('label_id', (int) $group['label_id'])
->first();
if ($section !== null) {
return $section->id;
}
}
throw ValidationException::withMessages([
'groups' => 'Bitte wähle gültige Song-Sektionen aus.',
]);
})->values();
return $sectionIds;
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Config;
final class BookmarkletController extends Controller
{
public function show(Request $request): Response
{
$appUrl = rtrim($request->getSchemeAndHttpHost(), '/');
if ($appUrl === '') {
$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;
}
function send(text){
var ccliMatch = (text || '').match(/CCLI[\s#-]*(\d+)/i);
var payload = {
title: '',
author: '',
ccliId: ccliMatch ? ccliMatch[1] : '',
sourceUrl: location.href,
rawText: text || ''
};
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
window.open(APP_URL + '/songs/import-from-ccli-paste?prefill=' + encoded, '_blank');
}
var btn = document.querySelector('#generalCopyLyricsButton');
if(!btn){
alert('Kopier-Symbol nicht gefunden. Bitte öffne die Liedtext-Ansicht auf SongSelect und versuche es erneut.');
return;
}
var captured = null;
function onCopy(e){
try { captured = e.clipboardData.getData('text/plain'); } catch(err) {}
}
document.addEventListener('copy', onCopy, true);
btn.click();
setTimeout(function(){
document.removeEventListener('copy', onCopy, true);
if(captured && captured.trim()){
send(captured);
return;
}
if(navigator.clipboard && navigator.clipboard.readText){
navigator.clipboard.readText().then(function(text){ send(text); })
.catch(function(){ alert('Liedtext konnte nicht aus der Zwischenablage gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".'); });
} else {
alert('Liedtext konnte nicht gelesen werden. Bitte kopiere ihn manuell und nutze „Aus CCLI importieren".');
}
}, 250);
})();
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

@ -0,0 +1,177 @@
<?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

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Services\LabelsImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class LabelImportController extends Controller
{
public function __construct(
private readonly LabelsImportService $importService,
) {}
public function store(Request $request): JsonResponse
{
$request->validate(['file' => ['required', 'file', 'max:5120']]);
$file = $request->file('file');
$tempPath = $file->getPathname();
try {
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
} catch (Throwable $e) {
return response()->json([
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Labels-Datei ist.',
], 422);
}
return response()->json([
'new' => $result->newCount,
'updated' => $result->updatedCount,
'total' => $result->totalInFile,
]);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MacroAssignmentController extends Controller
{
public function index(): Response
{
return Inertia::render('Settings', [
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
'macros' => Macro::with('collections')->orderBy('name')->get(),
'labels' => Label::orderBy('name')->get(),
'collections' => MacroCollection::orderBy('name')->get(),
'last_macros_import' => [
'at' => Setting::get('macros_last_imported_at'),
'filename' => Setting::get('macros_last_imported_filename'),
],
'last_labels_import' => [
'at' => Setting::get('labels_last_imported_at'),
'filename' => Setting::get('labels_last_imported_filename'),
],
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
'macro_id' => ['required', 'integer', 'exists:macros,id'],
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['integer', 'min:0'],
]);
$assignment = MacroAssignment::create($validated);
return response()->json(['id' => $assignment->id, 'success' => true]);
}
public function update(Request $request, MacroAssignment $macroAssignment): JsonResponse
{
$validated = $request->validate([
'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item'],
'macro_id' => ['sometimes', 'integer', 'exists:macros,id'],
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['sometimes', 'integer', 'min:0'],
]);
$macroAssignment->update($validated);
return response()->json(['success' => true]);
}
public function destroy(MacroAssignment $macroAssignment): JsonResponse
{
$macroAssignment->delete();
return response()->json(['success' => true]);
}
public function reorder(Request $request): JsonResponse
{
$validated = $request->validate([
'assignments' => ['required', 'array'],
'assignments.*.id' => ['required', 'integer', 'exists:macro_assignments,id'],
'assignments.*.order' => ['required', 'integer', 'min:0'],
]);
foreach ($validated['assignments'] as $item) {
MacroAssignment::where('id', $item['id'])->update(['order' => $item['order']]);
}
return response()->json(['success' => true]);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Services\MacrosImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class MacroImportController extends Controller
{
public function __construct(
private readonly MacrosImportService $importService,
) {}
public function store(Request $request): JsonResponse
{
$request->validate(['file' => ['required', 'file', 'max:5120']]);
$file = $request->file('file');
$tempPath = $file->getPathname();
try {
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
} catch (Throwable $e) {
return response()->json([
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Makro-Datei ist.',
], 422);
}
return response()->json([
'stats' => [
'new' => $result->new,
'updated' => $result->updated,
'disabled' => $result->disabled,
're_enabled' => $result->reEnabled,
],
'warnings' => $result->warnings,
]);
}
}

View file

@ -49,15 +49,23 @@ public function importPro(Request $request): JsonResponse
public function downloadPro(Song $song): BinaryFileResponse
{
if ($song->groups()->count() === 0) {
if ($this->countSongLabels($song) === 0) {
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
}
$exportService = new ProExportService;
$exportService = app(ProExportService::class);
$tempPath = $exportService->generateProFile($song);
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
}
private function countSongLabels(Song $song): int
{
return $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
}
}

View file

@ -4,10 +4,12 @@
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceMacroOverride;
use App\Models\Setting;
use App\Models\Slide;
use App\Models\Song;
use App\Services\AgendaMatcherService;
use App\Services\MacroResolutionService;
use App\Services\ProBundleExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -124,23 +126,23 @@ public function index(): Response
]);
}
public function edit(Service $service): Response
public function edit(Service $service, \App\Services\ServiceImageResolver $imageResolver): Response
{
$service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song',
'serviceSongs.song.groups',
'serviceSongs.song.arrangements.arrangementGroups.group',
'serviceSongs.song.arrangements.arrangementSections.section.label',
'serviceSongs.arrangement',
'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides',
'agendaItems.serviceSong.song.groups.slides',
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
]);
$songsCatalog = Song::query()
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')])
->orderBy('title')
->get(['id', 'title', 'ccli_id', 'has_translation'])
->map(fn (Song $song) => [
@ -148,6 +150,7 @@ public function edit(Service $service): Response
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation,
'has_content' => (int) $song->content_slides_count > 0,
])
->values();
@ -227,6 +230,41 @@ public function edit(Service $service): Response
return $arr;
}, $filteredItems);
// Macro resolution per part type (for icons + Anpassen/Standard panel)
$resolver = app(MacroResolutionService::class);
$macros_per_part = [];
foreach (['information', 'moderation', 'sermon', 'song', 'agenda_item'] as $partType) {
$assignments = $resolver->resolveAssignmentsForPart($service, $partType);
$isOverridden = ServiceMacroOverride::where('service_id', $service->id)
->where('part_type', $partType)
->exists();
$hasWarning = $assignments->contains(
fn ($a) => $a->macro?->isHidden() || ($a->position === 'by_label' && $a->label?->isHidden())
);
$macros_per_part[$partType] = [
'count' => $assignments->count(),
'is_overridden' => $isOverridden,
'has_warning' => $hasWarning,
'assignments' => $assignments->map(fn ($a) => [
'id' => $a->id,
'macro_id' => $a->macro_id,
'macro_name' => $a->macro?->name,
'macro_color' => $a->macro?->color,
'macro_hidden' => $a->macro?->isHidden(),
'position' => $a->position,
'label_id' => $a->label_id,
'label_name' => $a->label?->name,
])->values()->all(),
];
}
// Resolve key-visual/background live (per-service override → current global default → none),
// so the panels always reflect the CURRENT default even if it changed after creation/sync.
$resolvedKeyVisual = $imageResolver->keyVisualFor($service);
$resolvedBackground = $imageResolver->backgroundFor($service);
$keyVisualIsOwn = $service->key_visual_filename !== null && $resolvedKeyVisual === $service->key_visual_filename;
$backgroundIsOwn = $service->background_filename !== null && $resolvedBackground === $service->background_filename;
return Inertia::render('Services/Edit', [
'service' => [
'id' => $service->id,
@ -237,6 +275,14 @@ public function edit(Service $service): Response
'finalized_at' => $service->finalized_at?->toJSON(),
'last_synced_at' => $service->last_synced_at?->toJSON(),
'has_agenda' => $service->has_agenda,
'key_visual_filename' => $resolvedKeyVisual,
'background_filename' => $resolvedBackground,
'key_visual_url' => $resolvedKeyVisual ? '/storage/'.$resolvedKeyVisual : null,
'background_url' => $resolvedBackground ? '/storage/'.$resolvedBackground : null,
'key_visual_is_own' => $keyVisualIsOwn,
'background_is_own' => $backgroundIsOwn,
'moderator_name' => $service->moderator_name,
'preacher_name_override' => $service->preacher_name_override,
],
'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
'id' => $ss->id,
@ -253,15 +299,7 @@ public function edit(Service $service): Response
'title' => $ss->song->title,
'ccli_id' => $ss->song->ccli_id,
'has_translation' => $ss->song->has_translation,
'groups' => $ss->song->groups
->sortBy('order')
->values()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
]),
'groups' => $this->collectSongLabels($ss->song),
'arrangements' => $ss->song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->values()
@ -269,13 +307,14 @@ public function edit(Service $service): Response
'id' => $arrangement->id,
'name' => $arrangement->name,
'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementGroups
'groups' => $arrangement->arrangementSections
->sortBy('order')
->values()
->map(fn ($arrangementGroup) => [
'id' => $arrangementGroup->group?->id,
'name' => $arrangementGroup->group?->name,
'color' => $arrangementGroup->group?->color,
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->label?->id,
'section_id' => $arrangementSection->section?->id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
])
->filter(fn ($group) => $group['id'] !== null)
->values(),
@ -302,6 +341,7 @@ public function edit(Service $service): Response
'title' => $nextService->title,
'date' => $nextService->date?->toDateString(),
] : null,
'macros_per_part' => $macros_per_part,
]);
}
@ -322,12 +362,38 @@ public function finalize(Service $service): JsonResponse
'finalized_at' => now(),
]);
$resolver = app(\App\Services\ServiceImageResolver::class);
$keyVisual = $resolver->keyVisualFor($service);
$background = $resolver->backgroundFor($service);
$updates = [];
if ($keyVisual !== null && $service->key_visual_filename === null) {
$updates['key_visual_filename'] = $keyVisual;
}
if ($background !== null && $service->background_filename === null) {
$updates['background_filename'] = $background;
}
if ($updates !== []) {
$service->update($updates);
}
return response()->json([
'needs_confirmation' => false,
'success' => 'Service wurde abgeschlossen.',
]);
}
public function updateNameOverrides(Request $request, Service $service): RedirectResponse
{
$validated = $request->validate([
'moderator_name' => ['nullable', 'string', 'max:255'],
'preacher_name_override' => ['nullable', 'string', 'max:255'],
]);
$service->update($validated);
return back()->with('success', 'Namensangaben gespeichert.');
}
public function reopen(Service $service): RedirectResponse
{
$service->update([
@ -412,4 +478,26 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
)
->deleteFileAfterSend(true);
}
private function collectSongLabels(Song $song): \Illuminate\Support\Collection
{
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
if ($defaultArr === null) {
return collect();
}
return $defaultArr->arrangementSections
->sortBy('order')
->values()
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->label?->id,
'section_id' => $arrangementSection->section?->id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order,
])
->filter(fn ($group) => $group['id'] !== null)
->values();
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\Setting;
use App\Services\FileConversionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ServiceImageController extends Controller
{
public function storeKeyVisual(Request $request, Service $service): RedirectResponse
{
return $this->store($request, $service, 'key_visual_filename', 'current_key_visual');
}
public function storeBackground(Request $request, Service $service): RedirectResponse
{
return $this->store($request, $service, 'background_filename', 'current_background');
}
private function store(Request $request, Service $service, string $column, string $settingKey): RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:20480'],
'scope' => ['required', Rule::in(['service', 'default'])],
], [
'file.required' => 'Bitte wähle eine Bilddatei aus.',
'file.file' => 'Die hochgeladene Datei ist ungültig.',
'file.mimes' => 'Nur Bilddateien (jpg, png) sind erlaubt.',
'file.max' => 'Die Datei darf maximal 20 MB groß sein.',
'scope.required' => 'Bitte wähle einen Geltungsbereich.',
'scope.in' => 'Der gewählte Geltungsbereich ist ungültig.',
]);
$result = app(FileConversionService::class)->convertImageCover($request->file('file'));
$service->update([$column => $result['filename']]);
if ($request->input('scope') === 'default') {
Setting::set($settingKey, $result['filename']);
}
return back()->with('success', 'Bild wurde gespeichert.');
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceMacroOverrideController extends Controller
{
public function store(Request $request, Service $service): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
]);
ServiceMacroOverride::firstOrCreate([
'service_id' => $service->id,
'part_type' => $validated['part_type'],
]);
$globals = MacroAssignment::where('part_type', $validated['part_type'])->orderBy('order')->get();
foreach ($globals as $global) {
ServiceMacroAssignment::firstOrCreate([
'service_id' => $service->id,
'part_type' => $validated['part_type'],
'macro_id' => $global->macro_id,
'position' => $global->position,
'label_id' => $global->label_id,
'order' => $global->order,
]);
}
return response()->json(['success' => true]);
}
public function destroy(Service $service, Request $request): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
]);
ServiceMacroOverride::where('service_id', $service->id)
->where('part_type', $validated['part_type'])
->delete();
ServiceMacroAssignment::where('service_id', $service->id)
->where('part_type', $validated['part_type'])
->delete();
return response()->json(['success' => true]);
}
public function storeAssignment(Request $request, Service $service): JsonResponse
{
$validated = $request->validate([
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
'macro_id' => ['required', 'integer', 'exists:macros,id'],
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['integer', 'min:0'],
]);
$assignment = ServiceMacroAssignment::create([
'service_id' => $service->id,
...$validated,
]);
return response()->json(['id' => $assignment->id, 'success' => true]);
}
public function updateAssignment(Request $request, Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
{
$validated = $request->validate([
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
'order' => ['sometimes', 'integer', 'min:0'],
]);
$serviceMacroAssignment->update($validated);
return response()->json(['success' => true]);
}
public function destroyAssignment(Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
{
$serviceMacroAssignment->delete();
return response()->json(['success' => true]);
}
}

View file

@ -2,44 +2,70 @@
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class SettingsController extends Controller
{
private const MACRO_KEYS = [
'macro_name',
'macro_uuid',
'macro_collection_name',
'macro_collection_uuid',
private const AGENDA_KEYS = [
'agenda_start_title',
'agenda_end_title',
'agenda_announcement_position',
'agenda_sermon_matching',
'default_translation_language',
'current_key_visual',
'current_background',
'namenseinblender_macro_name',
'namenseinblender_macro_uuid',
'namenseinblender_macro_collection_name',
'namenseinblender_macro_collection_uuid',
];
public function index(): Response
{
$settings = [];
foreach (self::MACRO_KEYS as $key) {
foreach (self::AGENDA_KEYS as $key) {
$settings[$key] = Setting::get($key);
}
return Inertia::render('Settings', [
'settings' => $settings,
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
'macros' => Macro::with('collections')->orderBy('name')->get(),
'labels' => Label::orderBy('name')->get(),
'collections' => MacroCollection::with('macros')->orderBy('name')->get(),
'last_macros_import' => [
'at' => Setting::get('macros_last_imported_at'),
'filename' => Setting::get('macros_last_imported_filename'),
],
'last_labels_import' => [
'at' => Setting::get('labels_last_imported_at'),
'filename' => Setting::get('labels_last_imported_filename'),
],
]);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'key' => ['required', 'string', 'in:'.implode(',', self::MACRO_KEYS)],
'key' => ['required', 'string', Rule::in(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

@ -169,6 +169,7 @@ private function handleImage(
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'cover_mode' => $result['fullCover'] ?? null,
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
@ -260,6 +261,7 @@ private function handleZip(
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'cover_mode' => $result['fullCover'] ?? null,
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\SongRequest;
use App\Models\Label;
use App\Models\Song;
use App\Services\SongService;
use Illuminate\Http\JsonResponse;
@ -15,12 +16,10 @@ public function __construct(
private readonly SongService $songService,
) {}
/**
* Alle Songs auflisten (paginiert, durchsuchbar).
*/
public function index(Request $request): JsonResponse
{
$query = Song::query();
$query = Song::query()
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')]);
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
@ -29,6 +28,12 @@ public function index(Request $request): JsonResponse
});
}
// The SongDB UI sends with_content=1 by default to hide songs without content;
// pass with_content=0 (or omit) to include empty songs.
if ($request->boolean('with_content')) {
$query->whereHas('sections', fn ($q) => $q->has('slides'));
}
$songs = $query->orderBy('title')
->paginate($request->input('per_page', 20));
@ -39,6 +44,7 @@ public function index(Request $request): JsonResponse
'ccli_id' => $song->ccli_id,
'author' => $song->author,
'has_translation' => $song->has_translation,
'has_content' => (int) $song->content_slides_count > 0,
'last_used_at' => $song->last_used_at?->toDateString(),
'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(),
@ -53,15 +59,11 @@ public function index(Request $request): JsonResponse
]);
}
/**
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
*/
public function store(SongRequest $request): JsonResponse
{
$song = DB::transaction(function () use ($request) {
$song = Song::create($request->validated());
$this->songService->createDefaultGroups($song);
$this->songService->createDefaultArrangement($song);
return $song;
@ -69,16 +71,13 @@ public function store(SongRequest $request): JsonResponse
return response()->json([
'message' => 'Song erfolgreich erstellt',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
], 201);
}
/**
* Song mit Gruppen, Slides und Arrangements anzeigen.
*/
public function show(int $id): JsonResponse
{
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
$song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
@ -89,9 +88,6 @@ public function show(int $id): JsonResponse
]);
}
/**
* Song-Metadaten aktualisieren.
*/
public function update(SongRequest $request, int $id): JsonResponse
{
$song = Song::find($id);
@ -104,13 +100,10 @@ public function update(SongRequest $request, int $id): JsonResponse
return response()->json([
'message' => 'Song erfolgreich aktualisiert',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
]);
}
/**
* Song soft-löschen.
*/
public function destroy(int $id): JsonResponse
{
$song = Song::find($id);
@ -126,11 +119,37 @@ public function destroy(int $id): JsonResponse
]);
}
/**
* Song-Detail formatieren.
*/
private function formatSongDetail(Song $song): array
public function formatSongDetail(Song $song): array
{
$defaultArr = $song->arrangements->firstWhere('is_default', true);
$groupsPayload = [];
if ($defaultArr !== null) {
$groupsPayload = $defaultArr->arrangementSections
->sortBy('order')
->values()
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->id,
'section_id' => $arrangementSection->section?->id,
'label_id' => $arrangementSection->section?->label_id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order,
'slides' => $arrangementSection->section
? $arrangementSection->section->slides
->sortBy('order')
->values()
->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
'notes' => $slide->notes,
])->toArray()
: [],
])->toArray();
}
return [
'id' => $song->id,
'title' => $song->title,
@ -144,27 +163,25 @@ private function formatSongDetail(Song $song): array
'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(),
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
'notes' => $slide->notes,
])->toArray(),
'groups' => $groupsPayload,
'available_labels' => Label::query()
->whereNull('hidden_at')
->orderBy('name')
->get(['id', 'name', 'color'])
->map(fn (Label $label) => [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
])->toArray(),
'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id,
'name' => $arr->name,
'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
'id' => $ag->id,
'song_group_id' => $ag->song_group_id,
'order' => $ag->order,
'arrangement_groups' => $arr->arrangementSections->sortBy('order')->values()->map(fn ($arrangementSection) => [
'id' => $arrangementSection->id,
'section_id' => $arrangementSection->song_section_id,
'label_id' => $arrangementSection->section?->label_id,
'order' => $arrangementSection->order,
])->toArray(),
])->toArray(),
];

View file

@ -57,21 +57,27 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
private function buildGroupsInOrder(SongArrangement $arrangement): array
{
$arrangement->load([
'arrangementGroups' => fn ($query) => $query->orderBy('order'),
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
'arrangementSections' => fn ($query) => $query->orderBy('order'),
'arrangementSections.section.slides' => fn ($query) => $query->orderBy('order'),
'arrangementSections.section.label',
]);
return $arrangement->arrangementGroups->map(function ($arrangementGroup) {
$group = $arrangementGroup->group;
return $arrangement->arrangementSections->map(function ($arrangementSection) {
$section = $arrangementSection->section;
$label = $section?->label;
if ($section === null || $label === null) {
return null;
}
return [
'name' => $group->name,
'color' => $group->color ?? '#6b7280',
'slides' => $group->slides->map(fn ($slide) => [
'name' => $label->name,
'color' => $label->color ?? '#6b7280',
'slides' => $section->slides->map(fn ($slide) => [
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values()->all(),
];
})->values()->all();
})->filter()->values()->all();
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use App\Support\CcliLabels;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SongSectionController extends Controller
{
private const DEFAULT_LABEL_COLOR = '#3B82F6';
public function __construct(
private readonly SongController $songController,
) {}
public function store(Request $request, Song $song): JsonResponse
{
$data = $request->validate([
'label_name' => ['required', 'string', 'max:255'],
'color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'slides' => ['array'],
'slides.*.text_content' => ['required', 'string'],
'slides.*.text_content_translated' => ['nullable', 'string'],
], $this->validationMessages());
$normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']);
$responseSong = DB::transaction(function () use ($song, $data, $normalizedLabelName): Song {
$label = Label::firstOrCreate(
['name' => $normalizedLabelName],
['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR],
);
if ($song->sections()->where('label_id', $label->id)->exists()) {
abort(response()->json([
'message' => 'Dieser Abschnitt existiert bereits in diesem Lied.',
], 422));
}
$section = $song->sections()->create([
'label_id' => $label->id,
'order' => ((int) $song->sections()->max('order')) + 1,
]);
$this->replaceSlides($section, $data['slides'] ?? []);
$defaultArrangement = $song->arrangements()->firstOrCreate(
['is_default' => true],
['name' => 'Normal'],
);
$defaultArrangement->arrangementSections()->create([
'song_section_id' => $section->id,
'order' => ((int) $defaultArrangement->arrangementSections()->max('order')) + 1,
]);
$this->recomputeHasTranslation($song);
return $this->freshSong($song);
});
return response()->json([
'message' => 'Sektion wurde hinzugefügt.',
'data' => $this->songController->formatSongDetail($responseSong),
], 201);
}
public function update(Request $request, Song $song, SongSection $section): JsonResponse
{
if ((int) $section->song_id !== (int) $song->id) {
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
}
$data = $request->validate([
'slides' => ['required', 'array'],
'slides.*.text_content' => ['required', 'string'],
'slides.*.text_content_translated' => ['nullable', 'string'],
'order' => ['sometimes', 'integer'],
], $this->validationMessages());
$responseSong = DB::transaction(function () use ($song, $section, $data): Song {
if (array_key_exists('order', $data)) {
$section->update(['order' => $data['order']]);
}
$this->replaceSlides($section, $data['slides']);
$this->recomputeHasTranslation($song);
return $this->freshSong($song);
});
return response()->json([
'message' => 'Sektion wurde gespeichert.',
'data' => $this->songController->formatSongDetail($responseSong),
]);
}
public function destroy(Song $song, SongSection $section): JsonResponse
{
if ((int) $section->song_id !== (int) $song->id) {
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
}
$responseSong = DB::transaction(function () use ($song, $section): Song {
SongArrangementSection::query()
->where('song_section_id', $section->id)
->whereHas('arrangement', fn ($query) => $query->where('song_id', $song->id))
->delete();
$section->slides()->delete();
$section->delete();
$this->recomputeHasTranslation($song);
return $this->freshSong($song);
});
return response()->json([
'message' => 'Sektion wurde gelöscht.',
'data' => $this->songController->formatSongDetail($responseSong),
]);
}
private function replaceSlides(SongSection $section, array $slides): void
{
$section->slides()->delete();
foreach (array_values($slides) as $index => $slide) {
$section->slides()->create([
'order' => $index + 1,
'text_content' => $slide['text_content'],
'text_content_translated' => $slide['text_content_translated'] ?? null,
]);
}
}
private function recomputeHasTranslation(Song $song): void
{
$hasTranslation = $song->sections()
->whereHas('slides', fn ($query) => $query
->whereNotNull('text_content_translated')
->where('text_content_translated', '!=', ''))
->exists();
$song->update(['has_translation' => $hasTranslation]);
}
private function freshSong(Song $song): Song
{
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
}
private function validationMessages(): array
{
return [
'label_name.required' => 'Bitte gib einen Namen für die Sektion ein.',
'label_name.string' => 'Der Sektionsname muss ein Text sein.',
'label_name.max' => 'Der Sektionsname darf höchstens 255 Zeichen lang sein.',
'color.regex' => 'Bitte gib eine gültige Hex-Farbe an.',
'slides.required' => 'Bitte gib mindestens eine Folie an.',
'slides.array' => 'Die Folien müssen als Liste gesendet werden.',
'slides.*.text_content.required' => 'Bitte gib einen Text für jede Folie ein.',
'slides.*.text_content.string' => 'Der Folientext muss ein Text sein.',
'slides.*.text_content_translated.string' => 'Der übersetzte Folientext muss ein Text sein.',
'order.integer' => 'Die Reihenfolge muss eine Zahl sein.',
];
}
}

View file

@ -18,41 +18,52 @@ public function __construct(
public function page(Song $song): Response
{
$song->load([
'groups' => fn ($query) => $query
->orderBy('order')
->with([
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
]),
'arrangements' => fn ($q) => $q->where('is_default', true),
'arrangements.arrangementSections' => fn ($q) => $q->orderBy('order'),
'arrangements.arrangementSections.section.slides',
'arrangements.arrangementSections.section.label',
]);
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
$groups = collect();
if ($defaultArr !== null) {
$groups = $defaultArr->arrangementSections
->sortBy('order')
->values()
->map(fn ($arrangementSection) => [
'id' => $arrangementSection->section?->id,
'section_id' => $arrangementSection->section?->id,
'label_id' => $arrangementSection->section?->label_id,
'name' => $arrangementSection->section?->label?->name,
'color' => $arrangementSection->section?->label?->color,
'order' => $arrangementSection->order,
'slides' => $arrangementSection->section
? $arrangementSection->section->slides
->sortBy('order')
->values()
->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values()
: collect(),
]);
}
return Inertia::render('Songs/Translate', [
'song' => [
'id' => $song->id,
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation,
'groups' => $song->groups->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
'slides' => $group->slides->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values(),
])->values(),
'groups' => $groups,
],
'prefilledTranslation' => session()->pull('ccli_prefilled'),
]);
}
/**
* URL abrufen und Text zum Prüfen zurückgeben.
*
* Der Text wird NICHT automatisch gespeichert der Benutzer
* prüft ihn zuerst und importiert dann explizit.
*/
public function fetchUrl(Request $request): JsonResponse
{
$request->validate([
@ -72,11 +83,6 @@ public function fetchUrl(Request $request): JsonResponse
]);
}
/**
* Übersetzungstext für einen Song importieren.
*
* Verteilt den Text zeilenweise auf die Slides des Songs.
*/
public function import(int $songId, Request $request): JsonResponse
{
$song = Song::find($songId);
@ -98,9 +104,6 @@ public function import(int $songId, Request $request): JsonResponse
]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*/
public function destroy(int $songId): JsonResponse
{
$song = Song::find($songId);

View file

@ -53,6 +53,14 @@ public function share(Request $request): array
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
],
'namenseinblenderMacro' => [
'name' => Setting::get('namenseinblender_macro_name'),
'uuid' => Setting::get('namenseinblender_macro_uuid'),
'collection_name' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
'collection_uuid' => Setting::get('namenseinblender_macro_collection_uuid'),
],
'currentKeyVisual' => Setting::get('current_key_visual'),
'currentBackground' => Setting::get('current_background'),
];
}
}

42
app/Models/Label.php Normal file
View file

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Label extends Model
{
use HasFactory;
protected $fillable = [
'name',
'color',
'hidden_at',
'last_imported_at',
];
protected function casts(): array
{
return [
'hidden_at' => 'datetime',
'last_imported_at' => 'datetime',
];
}
public function macroAssignments(): HasMany
{
return $this->hasMany(MacroAssignment::class);
}
public function sections(): HasMany
{
return $this->hasMany(SongSection::class);
}
public function isHidden(): bool
{
return $this->hidden_at !== null;
}
}

51
app/Models/Macro.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Macro extends Model
{
use HasFactory;
protected $fillable = [
'uuid',
'name',
'color',
'trigger_on_startup',
'image_type',
'action_count',
'hidden_at',
'last_imported_at',
'last_imported_filename',
];
protected function casts(): array
{
return [
'trigger_on_startup' => 'boolean',
'hidden_at' => 'datetime',
'last_imported_at' => 'datetime',
];
}
public function collections(): BelongsToMany
{
return $this->belongsToMany(MacroCollection::class, 'macro_collection_macros')
->withPivot('order')
->orderBy('macro_collection_macros.order');
}
public function assignments(): HasMany
{
return $this->hasMany(MacroAssignment::class);
}
public function isHidden(): bool
{
return $this->hidden_at !== null;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MacroAssignment extends Model
{
protected $fillable = [
'part_type',
'macro_id',
'position',
'label_id',
'order',
];
public function macro(): BelongsTo
{
return $this->belongsTo(Macro::class);
}
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class MacroCollection extends Model
{
protected $fillable = [
'uuid',
'name',
'last_imported_at',
];
protected function casts(): array
{
return [
'last_imported_at' => 'datetime',
];
}
public function macros(): BelongsToMany
{
return $this->belongsToMany(Macro::class, 'macro_collection_macros')
->withPivot('order')
->orderBy('macro_collection_macros.order');
}
}

View file

@ -17,7 +17,11 @@ class Service extends Model
'title',
'date',
'preacher_name',
'preacher_name_override',
'beamer_tech_name',
'key_visual_filename',
'background_filename',
'moderator_name',
'finalized_at',
'last_synced_at',
'cts_data',
@ -45,6 +49,16 @@ public function slides(): HasMany
return $this->hasMany(Slide::class);
}
protected function keyVisualUrl(): Attribute
{
return Attribute::get(fn () => $this->key_visual_filename ? '/storage/'.$this->key_visual_filename : null);
}
protected function backgroundUrl(): Attribute
{
return Attribute::get(fn () => $this->background_filename ? '/storage/'.$this->background_filename : null);
}
public function agendaItems(): HasMany
{
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');

View file

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceMacroAssignment extends Model
{
protected $fillable = [
'service_id',
'part_type',
'macro_id',
'position',
'label_id',
'order',
];
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function macro(): BelongsTo
{
return $this->belongsTo(Macro::class);
}
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ServiceMacroOverride extends Model
{
protected $fillable = [
'service_id',
'part_type',
];
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function assignments(): HasMany
{
return $this->hasMany(ServiceMacroAssignment::class, 'service_id', 'service_id')
->where('part_type', $this->part_type);
}
}

View file

@ -19,6 +19,7 @@ class Slide extends Model
'original_filename',
'stored_filename',
'thumbnail_filename',
'cover_mode',
'expire_date',
'uploader_name',
'uploaded_at',
@ -30,6 +31,7 @@ protected function casts(): array
return [
'expire_date' => 'date',
'uploaded_at' => 'datetime',
'cover_mode' => 'boolean',
];
}

View file

@ -16,6 +16,8 @@ class Song extends Model
protected $fillable = [
'ccli_id',
'cts_song_id',
'imported_from_ccli_at',
'ccli_source_url',
'title',
'author',
'copyright_text',
@ -30,19 +32,20 @@ protected function casts(): array
return [
'has_translation' => 'boolean',
'last_used_at' => 'datetime',
'imported_from_ccli_at' => 'datetime',
];
}
public function groups(): HasMany
{
return $this->hasMany(SongGroup::class);
}
public function arrangements(): HasMany
{
return $this->hasMany(SongArrangement::class);
}
public function sections(): HasMany
{
return $this->hasMany(SongSection::class)->orderBy('order');
}
public function serviceSongs(): HasMany
{
return $this->hasMany(ServiceSong::class);

View file

@ -29,9 +29,14 @@ public function song(): BelongsTo
return $this->belongsTo(Song::class);
}
public function arrangementGroups(): HasMany
public function arrangementLabels(): HasMany
{
return $this->hasMany(SongArrangementGroup::class);
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
}
public function arrangementSections(): HasMany
{
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
}
public function serviceSongs(): HasMany

View file

@ -0,0 +1,5 @@
<?php
namespace App\Models;
class SongArrangementLabel extends SongArrangementSection {}

View file

@ -6,13 +6,15 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SongArrangementGroup extends Model
class SongArrangementSection extends Model
{
use HasFactory;
protected $table = 'song_arrangement_labels';
protected $fillable = [
'song_arrangement_id',
'song_group_id',
'song_section_id',
'order',
];
@ -21,8 +23,8 @@ public function arrangement(): BelongsTo
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
}
public function group(): BelongsTo
public function section(): BelongsTo
{
return $this->belongsTo(SongGroup::class, 'song_group_id');
return $this->belongsTo(SongSection::class, 'song_section_id');
}
}

View file

@ -7,29 +7,35 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SongGroup extends Model
class SongSection extends Model
{
use HasFactory;
protected $fillable = [
'song_id',
'name',
'color',
'label_id',
'order',
];
protected function casts(): array
{
return [
'order' => 'integer',
];
}
public function song(): BelongsTo
{
return $this->belongsTo(Song::class);
}
public function slides(): HasMany
public function label(): BelongsTo
{
return $this->hasMany(SongSlide::class);
return $this->belongsTo(Label::class);
}
public function arrangementGroups(): HasMany
public function slides(): HasMany
{
return $this->hasMany(SongArrangementGroup::class);
return $this->hasMany(SongSlide::class, 'song_section_id')->orderBy('order');
}
}

View file

@ -11,15 +11,15 @@ class SongSlide extends Model
use HasFactory;
protected $fillable = [
'song_group_id',
'song_section_id',
'order',
'text_content',
'text_content_translated',
'notes',
];
public function group(): BelongsTo
public function section(): BelongsTo
{
return $this->belongsTo(SongGroup::class, 'song_group_id');
return $this->belongsTo(SongSection::class, 'song_section_id');
}
}

View file

@ -0,0 +1,195 @@
<?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\SongSection;
use App\Services\DTO\ParsedCcliSection;
use App\Services\DTO\ParsedCcliSong;
use App\Support\CcliLabels;
use Illuminate\Support\Facades\DB;
use RuntimeException;
final class CcliImportService
{
/**
* Number of lyric lines grouped into a single projection slide.
*/
private const LINES_PER_SLIDE = 2;
private const LABEL_KIND_COLORS = [
'Verse' => '#3B82F6',
'Chorus' => '#10B981',
'Bridge' => '#F59E0B',
'Pre-Chorus' => '#8B5CF6',
'Tag' => '#EC4899',
'Ending' => '#EF4444',
'Intro' => '#14B8A6',
'Interlude' => '#6366F1',
'Outro' => '#F97316',
'Misc' => '#64748B',
];
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() && $this->songHasContent($song)) {
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.';
}
$sectionIds = [];
$hasTranslation = false;
foreach ($parsed->sections as $order => $parsedSection) {
$label = $this->resolveLabel($parsedSection);
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $label->id],
['order' => $order + 1],
);
$section->update(['order' => $order + 1]);
$sectionIds[] = $section->id;
$section->slides()->delete();
// Group lines into pairs: each slide carries up to two lines.
$lineChunks = array_chunk($parsedSection->lines, self::LINES_PER_SLIDE);
$translatedChunks = $parsedSection->linesTranslated !== null
? array_chunk($parsedSection->linesTranslated, self::LINES_PER_SLIDE)
: [];
foreach ($lineChunks as $slideOrder => $chunk) {
$translatedChunk = $translatedChunks[$slideOrder] ?? null;
$translatedLine = $translatedChunk !== null ? implode("\n", $translatedChunk) : null;
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
$section->slides()->create([
'order' => $slideOrder + 1,
'text_content' => implode("\n", $chunk),
'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 ($sectionIds as $order => $sectionId) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $sectionId,
'order' => $order + 1,
]);
}
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
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 songHasContent(Song $song): bool
{
return $song->sections()->whereHas('slides')->exists();
}
private function resolveLabel(ParsedCcliSection $section): Label
{
$canonicalKind = CcliLabels::normalizeLabelName($section->kind);
$canonicalLabelName = CcliLabels::normalizeLabelName(
$section->kind.($section->number ? ' '.$section->number : ''),
);
return Label::firstOrCreate(
['name' => $canonicalLabelName],
['color' => $this->labelColor($canonicalKind), 'last_imported_at' => now()],
);
}
private function labelColor(string $canonicalKind): string
{
if (array_key_exists($canonicalKind, self::LABEL_KIND_COLORS)) {
return self::LABEL_KIND_COLORS[$canonicalKind];
}
$colors = array_values(self::LABEL_KIND_COLORS);
return $colors[crc32($canonicalKind) % count($colors)];
}
}

View file

@ -0,0 +1,206 @@
<?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;
$previousLineWasBlank = false;
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
foreach (array_slice($lines, $firstSectionIndex) as $line) {
if ($line === '') {
$previousLineWasBlank = true;
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
continue;
}
if ($isMetadataLine($line)) {
if ($author === null
&& $current !== null
&& $currentParagraphLineCount === 1
&& $currentParagraphStartedAfterBlank
) {
$author = array_pop($current['lines']);
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
}
$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' => [],
];
$previousLineWasBlank = false;
$currentParagraphLineCount = 0;
$currentParagraphStartedAfterBlank = false;
continue;
}
if ($current !== null) {
if ($currentParagraphLineCount === 0) {
$currentParagraphStartedAfterBlank = $previousLineWasBlank;
}
$current['lines'][] = $line;
$currentParagraphLineCount++;
$previousLineWasBlank = false;
}
}
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

@ -0,0 +1,149 @@
<?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.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
$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->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
$label = $section?->label;
if ($section === null || $label === null) {
continue;
}
$localCanonical = $this->canonicalLabel($label->name, null);
$matchedSection = $ccliByCanonical[$localCanonical] ?? null;
$slides = $section->slides->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

@ -28,8 +28,7 @@ public function __construct(
private readonly ?Closure $songFetcher = null,
private readonly ?Closure $agendaFetcher = null,
private readonly ?Closure $eventServiceFetcher = null,
) {
}
) {}
public function sync(): array
{

View file

@ -0,0 +1,12 @@
<?php
namespace App\Services\DTO;
final class LabelImportResult
{
public function __construct(
public readonly int $newCount,
public readonly int $updatedCount,
public readonly int $totalInFile,
) {}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Services\DTO;
final class MacroImportResult
{
public function __construct(
public readonly int $new,
public readonly int $updated,
public readonly int $disabled,
public readonly int $reEnabled,
public readonly array $warnings,
) {}
}

View file

@ -0,0 +1,17 @@
<?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

@ -0,0 +1,17 @@
<?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

@ -56,6 +56,45 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array
'filename' => $relativePath,
'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
'fullCover' => false,
];
}
public function convertImageCover(UploadedFile|string|SplFileInfo $file, string $disk = 'public'): array
{
$sourcePath = $this->resolvePath($file);
$extension = $this->resolveExtension($file, $sourcePath);
$this->assertSupported($extension);
if (! in_array($extension, self::IMAGE_EXTENSIONS, true)) {
throw new InvalidArgumentException('Nur Bilddateien koennen mit convertImageCover verarbeitet werden.');
}
$this->assertSize($file, $sourcePath);
$filename = Str::uuid()->toString().'.jpg';
$relativePath = 'slides/'.$filename;
$targetPath = Storage::disk($disk)->path($relativePath);
Storage::disk($disk)->makeDirectory('slides');
$this->ensureDirectory(dirname($targetPath));
$manager = $this->createImageManager();
$image = $manager->read($sourcePath);
$originalWidth = $image->width();
$originalHeight = $image->height();
$warnings = $this->checkCoverImageDimensions($originalWidth, $originalHeight);
$image->cover(1920, 1080);
$image->save($targetPath, quality: 90);
$thumbnailPath = $this->generateThumbnail($relativePath, $disk);
return [
'filename' => $relativePath,
'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
'fullCover' => true,
];
}
@ -143,11 +182,11 @@ public function processZip(UploadedFile|string|SplFileInfo $file): array
return $results;
}
public function generateThumbnail(string $path): string
public function generateThumbnail(string $path, string $disk = 'public'): string
{
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
? $path
: Storage::disk('public')->path($path);
: Storage::disk($disk)->path($path);
if (! is_file($absolutePath)) {
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
@ -155,8 +194,8 @@ public function generateThumbnail(string $path): string
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
$thumbnailRelativePath = 'slides/thumbnails/'.$filename;
$thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath);
Storage::disk('public')->makeDirectory('slides/thumbnails');
$thumbnailAbsolutePath = Storage::disk($disk)->path($thumbnailRelativePath);
Storage::disk($disk)->makeDirectory('slides/thumbnails');
$this->ensureDirectory(dirname($thumbnailAbsolutePath));
$manager = $this->createImageManager();
@ -279,6 +318,20 @@ private function checkImageDimensions(int $width, int $height): array
return $warnings;
}
/** @return string[] */
private function checkCoverImageDimensions(int $width, int $height): array
{
$warnings = [];
if ($width < 1920 || $height < 1080) {
$warnings[] = "Das Bild ({$width}×{$height}) ist kleiner als 1920×1080 und wurde hochskaliert. "
.'Dadurch kann die Qualität schlechter sein. '
.'Lade am besten Bilder mit mindestens 1920×1080 Pixeln hoch.';
}
return $warnings;
}
private function ensureDirectory(string $directory): void
{
if (is_dir($directory)) {

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Models\Label;
use App\Models\Setting;
use App\Services\DTO\LabelImportResult;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\LabelsFileReader;
class LabelsImportService
{
public function import(string $filePath, string $originalFilename): LabelImportResult
{
$library = LabelsFileReader::read($filePath);
$newCount = 0;
$updatedCount = 0;
DB::transaction(function () use ($library, &$newCount, &$updatedCount): void {
foreach ($library->getLabels() as $parserLabel) {
$name = $parserLabel->getName();
if ($name === '') {
continue;
}
$color = $parserLabel->getColorHex();
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
if ($existing === null) {
Label::create([
'name' => $name,
'color' => $color,
'last_imported_at' => now(),
]);
$newCount++;
} else {
$existing->update([
'color' => $color,
'last_imported_at' => now(),
]);
$updatedCount++;
}
}
});
Setting::set('labels_last_imported_at', now()->toIso8601String());
Setting::set('labels_last_imported_filename', $originalFilename);
return new LabelImportResult($newCount, $updatedCount, count($library->getLabels()));
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Services;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use Illuminate\Support\Collection;
class MacroResolutionService
{
/**
* Returns active (non-hidden) assignments for a given service + part type.
* Uses service-specific assignments if an override exists, otherwise global defaults.
*/
public function resolveAssignmentsForPart(Service $service, string $partType): Collection
{
$hasOverride = ServiceMacroOverride::where('service_id', $service->id)
->where('part_type', $partType)
->exists();
if ($hasOverride) {
$rows = ServiceMacroAssignment::with(['macro', 'label'])
->where('service_id', $service->id)
->where('part_type', $partType)
->orderBy('order')
->get();
} else {
$rows = MacroAssignment::with(['macro', 'label'])
->where('part_type', $partType)
->orderBy('order')
->get();
}
return $rows
->reject(fn ($r) => $r->macro === null || $r->macro->isHidden())
->reject(fn ($r) => $r->position === 'by_label' && ($r->label === null || $r->label->isHidden()));
}
/**
* Returns the macro export data for macros that apply to a specific slide.
*
* @param array $slideContext ['index' => int, 'total' => int, 'label_id' => int|null]
* @return array<int, array{name: string, uuid: string, collectionName: string, collectionUuid: string}>
*/
public function macrosForSlide(Service $service, string $partType, array $slideContext): array
{
$assignments = $this->resolveAssignmentsForPart($service, $partType);
$matched = $assignments->filter(function ($a) use ($slideContext) {
return match ($a->position) {
'all_slides' => true,
'first_slide' => $slideContext['index'] === 0,
'last_slide' => $slideContext['index'] === $slideContext['total'] - 1,
'by_label' => isset($slideContext['label_id'])
&& (int) $a->label_id === (int) $slideContext['label_id'],
default => false,
};
});
return $matched->map(fn ($a) => $this->toExportArray($a->macro))->values()->all();
}
/**
* Returns the count of active assignments for a service + part (for UI badges).
*/
public function countAssignmentsForPart(Service $service, string $partType): int
{
return $this->resolveAssignmentsForPart($service, $partType)->count();
}
private function toExportArray(Macro $macro): array
{
$collection = $macro->collections()->first();
return [
'name' => $macro->name,
'uuid' => $macro->uuid,
'collectionName' => $collection?->name ?? '--MAIN--',
'collectionUuid' => $collection?->uuid ?? '8D02FC57-83F8-4042-9B90-81C229728426',
];
}
}

View file

@ -0,0 +1,110 @@
<?php
namespace App\Services;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use App\Services\DTO\MacroImportResult;
use App\Support\MacroColorConverter;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\MacrosFileReader;
class MacrosImportService
{
public function import(string $filePath, string $originalFilename): MacroImportResult
{
$library = MacrosFileReader::read($filePath);
$stats = ['new' => 0, 'updated' => 0, 'disabled' => 0, 'reEnabled' => 0];
$importedUuids = [];
DB::transaction(function () use ($library, &$stats, &$importedUuids, $originalFilename): void {
foreach ($library->getMacros() as $parserMacro) {
$uuid = strtoupper($parserMacro->getUuid());
if ($uuid === '') {
continue;
}
$importedUuids[] = $uuid;
$color = MacroColorConverter::fromRgba($parserMacro->getColor());
$data = [
'uuid' => $uuid,
'name' => $parserMacro->getName(),
'color' => $color,
'trigger_on_startup' => $parserMacro->getTriggerOnStartup(),
'image_type' => $parserMacro->getImageType(),
'action_count' => $parserMacro->getActionCount(),
'last_imported_at' => now(),
'last_imported_filename' => $originalFilename,
'hidden_at' => null,
];
$existing = Macro::where('uuid', $uuid)->first();
if ($existing === null) {
Macro::create($data);
$stats['new']++;
} else {
$wasHidden = $existing->isHidden();
$existing->update($data);
if ($wasHidden) {
$stats['reEnabled']++;
} else {
$stats['updated']++;
}
}
}
if (! empty($importedUuids)) {
$stats['disabled'] = Macro::whereNotIn('uuid', $importedUuids)
->whereNull('hidden_at')
->update(['hidden_at' => now()]);
}
foreach ($library->getCollections() as $parserCollection) {
$collUuid = strtoupper($parserCollection->getUuid());
if ($collUuid === '') {
continue;
}
$collection = MacroCollection::updateOrCreate(
['uuid' => $collUuid],
['name' => $parserCollection->getName(), 'last_imported_at' => now()],
);
$collection->macros()->detach();
foreach ($parserCollection->getMacroUuids() as $idx => $macroUuid) {
$macro = Macro::where('uuid', strtoupper($macroUuid))->first();
if ($macro) {
$collection->macros()->attach($macro->id, ['order' => $idx]);
}
}
}
});
Setting::set('macros_last_imported_at', now()->toIso8601String());
Setting::set('macros_last_imported_filename', $originalFilename);
$warnings = $this->buildAssignmentWarnings();
return new MacroImportResult(
$stats['new'],
$stats['updated'],
$stats['disabled'],
$stats['reEnabled'],
$warnings,
);
}
private function buildAssignmentWarnings(): array
{
return MacroAssignment::whereHas('macro', fn ($q) => $q->whereNotNull('hidden_at'))
->with('macro')
->get()
->map(fn ($a) => [
'macro_name' => $a->macro->name,
'macro_uuid' => $a->macro->uuid,
'part_type' => $a->part_type,
])
->toArray();
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NameTagResolver
{
public function __construct(
private readonly AgendaMatcherService $agendaMatcherService,
) {}
public function moderatorFor(Service $service): ?string
{
$override = $this->filledString($service->moderator_name);
if ($override !== null) {
return $override;
}
$firstAgendaItem = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
->orderBy('id')
->first();
return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null;
}
public function preacherFor(Service $service): ?string
{
$override = $this->filledString($service->preacher_name_override);
if ($override !== null) {
return $override;
}
$preacherName = $this->filledString($service->preacher_name);
if ($preacherName !== null) {
return $preacherName;
}
$sermonItem = $service->agendaItems()
->where('is_before_event', false)
->whereNull('service_song_id')
->orderBy('sort_order')
->orderBy('id')
->get()
->first(fn (ServiceAgendaItem $item) => $this->isSermonItem($item));
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null;
}
private function filledString(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed === '' ? null : $trimmed;
}
private function namesFromResponsible(mixed $responsible): ?string
{
if (! is_array($responsible) || $responsible === []) {
return null;
}
$people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
$names = collect($people)
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
->filter()
->values()
->all();
return $names === [] ? null : implode(', ', $names);
}
private function nameFromResponsiblePerson(mixed $person): ?string
{
if (is_string($person)) {
return $this->filledString($person);
}
if (! is_array($person)) {
return null;
}
$name = $this->filledString($person['name'] ?? null);
if ($name !== null) {
return $name;
}
$firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? '';
$lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? '';
$fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName;
}
private function isSermonItem(ServiceAgendaItem $item): bool
{
$configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching'));
if ($configuredPatterns !== []) {
return $this->agendaMatcherService->matchesAny($item->title, $configuredPatterns);
}
$title = Str::lower($item->title);
$type = Str::lower($item->type ?? '');
return str_contains($title, 'predigt')
|| str_contains($title, 'sermon')
|| str_contains($type, 'predigt')
|| str_contains($type, 'sermon');
}
/** @return array<int, string> */
private function patternsFromSetting(?string $patterns): array
{
if ($patterns === null || trim($patterns) === '') {
return [];
}
return array_values(array_filter(
array_map(fn (string $pattern) => trim($pattern), explode(',', $patterns)),
fn (string $pattern) => $pattern !== '',
));
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Services;
use App\Models\Setting;
class NameTagSlideBuilder
{
public function buildModeratorSlide(string $name): ?array
{
return $this->build($name, 'Moderation');
}
public function buildPreacherSlide(string $name): ?array
{
return $this->build($name, 'Predigt');
}
public function build(string $name, string $title): ?array
{
$macroName = Setting::get('namenseinblender_macro_name');
if ($macroName === null || trim($macroName) === '') {
return null;
}
return [
'text' => $name."\n".$title,
'macro' => [
'name' => $macroName,
'uuid' => Setting::get('namenseinblender_macro_uuid'),
'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'),
],
];
}
}

View file

@ -19,7 +19,12 @@ public function generatePlaylist(Service $service): array
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
->where('is_before_event', false)
->orderBy('sort_order')
->with(['slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'serviceSong.song.groups.slides', 'serviceSong.arrangement.arrangementGroups.group'])
->with([
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.song.arrangements.arrangementSections.section.label',
'serviceSong.arrangement.arrangementSections.section.label',
])
->get();
if ($agendaItems->isEmpty()) {
@ -49,7 +54,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$announcementPatterns = Setting::get('agenda_announcement_position');
$announcementInserted = false;
$exportService = new ProExportService;
$exportService = app(ProExportService::class);
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
mkdir($tempDir, 0755, true);
@ -57,7 +62,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$embeddedFiles = [];
$skippedUnmatched = 0;
$moderatorSlideData = $this->buildModeratorSlideData($service);
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
foreach ($agendaItems as $item) {
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
$this->writeProAndEmbed(
'Moderator',
$moderatorSlideData,
$tempDir,
$playlistItems,
$embeddedFiles,
);
}
if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
$patterns = array_map('trim', explode(',', $announcementPatterns));
$matcher = app(AgendaMatcherService::class);
@ -69,6 +87,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
'information',
);
$announcementInserted = true;
}
@ -80,18 +100,19 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if ($serviceSong->song_id && $serviceSong->song) {
$song = $serviceSong->song;
if ($song->groups()->count() === 0) {
if ($this->countSongLabels($song) === 0) {
$skippedUnmatched++;
continue;
}
$proPath = $exportService->generateProFile($song);
$proPath = $exportService->generateProFile($song, $service);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath);
$embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [
'type' => 'presentation',
@ -106,6 +127,11 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
}
if ($item->slides->isNotEmpty()) {
if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') {
$this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt');
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
}
$label = $item->title ?: 'Folien';
$this->addSlidesFromCollection(
$item->slides,
@ -114,6 +140,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$this->backgroundPartTypeForAgendaItem($item),
);
continue;
}
if (! $this->isNameTagAgendaItem($item)) {
$this->addKeyVisualFallbackPresentation(
$item,
$service,
$tempDir,
$playlistItems,
$embeddedFiles,
);
}
}
@ -128,6 +168,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$prependItems,
$prependFiles,
$service,
'information',
);
$playlistItems = array_merge($prependItems, $playlistItems);
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
@ -161,18 +203,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
*/
private function generatePlaylistLegacy(Service $service): array
{
$service->loadMissing('serviceSongs.song.groups.slides');
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
$matchedSongs = $service->serviceSongs()
->whereNotNull('song_id')
->orderBy('order')
->with('song.groups.slides')
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
$skippedEmpty = 0;
$exportService = new ProExportService;
$exportService = app(ProExportService::class);
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
mkdir($tempDir, 0755, true);
@ -191,18 +233,19 @@ private function generatePlaylistLegacy(Service $service): array
foreach ($matchedSongs as $serviceSong) {
$song = $serviceSong->song;
if (! $song || $song->groups()->count() === 0) {
if (! $song || $this->countSongLabels($song) === 0) {
$skippedEmpty++;
continue;
}
$proPath = $exportService->generateProFile($song);
$proPath = $exportService->generateProFile($song, $service);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath);
$embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [
'type' => 'presentation',
@ -256,9 +299,13 @@ private function addSlidesFromCollection(
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
?Service $service = null,
?string $backgroundPartType = null,
): void {
$slideDataList = [];
$imageFiles = [];
$background = $this->backgroundData($service);
$backgroundAttached = false;
foreach ($slides->values() as $index => $slide) {
$storedPath = Storage::disk('public')->path($slide->stored_filename);
@ -273,11 +320,22 @@ private function addSlidesFromCollection(
$imageFiles[$imageFilename] = file_get_contents($destPath);
$slideDataList[] = [
$singleSlideData = [
'media' => $imageFilename,
'format' => 'JPG',
'label' => $slide->original_filename,
];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background;
$backgroundAttached = true;
}
$slideDataList[] = $singleSlideData;
}
if ($backgroundAttached) {
$this->embedBackground($service, $embeddedFiles);
}
if (empty($slideDataList)) {
@ -358,14 +416,262 @@ private function addSlidePresentation(
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$type,
);
}
private function addKeyVisualFallbackPresentation(
ServiceAgendaItem $item,
Service $service,
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
): void {
$background = $this->keyVisualData($service);
if ($background === null) {
return;
}
$this->embedKeyVisual($service, $embeddedFiles);
$label = $item->title ?: 'Keyvisual';
$groups = [
[
'name' => 'Keyvisual',
'color' => [0, 0, 0, 1],
'slides' => [
[
'imageOnly' => true,
'background' => $background,
],
],
],
];
$arrangements = [
[
'name' => 'normal',
'groupNames' => ['Keyvisual'],
],
];
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
$proFilename = $safeLabel.'.pro';
$proPath = $tempDir.'/'.$proFilename;
$this->writeProFile($proPath, $label, $groups, $arrangements);
$embeddedFiles[$proFilename] = file_get_contents($proPath);
$playlistItems[] = [
'type' => 'presentation',
'name' => $label,
'path' => $proFilename,
];
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
}
private function buildModeratorSlideData(Service $service): ?array
{
$name = app(NameTagResolver::class)->moderatorFor($service);
if ($name === null) {
return null;
}
return app(NameTagSlideBuilder::class)->buildModeratorSlide($name);
}
private function buildPreacherSlideData(Service $service): ?array
{
$name = app(NameTagResolver::class)->preacherFor($service);
if ($name === null) {
return null;
}
return app(NameTagSlideBuilder::class)->buildPreacherSlide($name);
}
private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void
{
$kvData = $this->keyVisualData($service);
if ($kvData === null) {
return;
}
$this->embedKeyVisual($service, $embeddedFiles);
$slideData = ['imageOnly' => true, 'background' => $kvData];
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
}
private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
{
$slideData = $this->buildPreacherSlideData($service);
if ($slideData === null) {
return;
}
$this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles);
}
private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
{
$groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]];
$arrangements = [['name' => 'normal', 'groupNames' => [$name]]];
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro';
$path = $tempDir.'/'.$filename;
$this->writeProFile($path, $name, $groups, $arrangements);
$embeddedFiles[$filename] = file_get_contents($path);
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
}
private function countSongLabels(\App\Models\Song $song): int
{
return $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
}
private function backgroundData(?Service $service): ?array
{
if ($service === null) {
return null;
}
if ($this->backgroundSourcePath($service) === null) {
return null;
}
return [
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
private function keyVisualData(Service $service): ?array
{
if ($this->keyVisualSourcePath($service) === null) {
return null;
}
return [
'path' => ServiceImageResolver::KEY_VISUAL_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
/** Absolute filesystem path of the resolved background image, or null. */
private function backgroundSourcePath(?Service $service): ?string
{
if ($service === null) {
return null;
}
$background = app(ServiceImageResolver::class)->backgroundFor($service);
if ($background === null || ! Storage::disk('public')->exists($background)) {
return null;
}
return Storage::disk('public')->path($background);
}
/** Absolute filesystem path of the resolved key-visual image, or null. */
private function keyVisualSourcePath(Service $service): ?string
{
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
if ($keyVisual === null || ! Storage::disk('public')->exists($keyVisual)) {
return null;
}
return Storage::disk('public')->path($keyVisual);
}
/** Embed the resolved background image bytes into the archive under the fixed export name. */
private function embedBackground(?Service $service, array &$embeddedFiles): void
{
$sourcePath = $this->backgroundSourcePath($service);
if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
return;
}
$contents = @file_get_contents($sourcePath);
if ($contents !== false) {
$embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
}
}
/** Embed the resolved key-visual image bytes into the archive under the fixed export name. */
private function embedKeyVisual(Service $service, array &$embeddedFiles): void
{
$sourcePath = $this->keyVisualSourcePath($service);
if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME])) {
return;
}
$contents = @file_get_contents($sourcePath);
if ($contents !== false) {
$embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME] = $contents;
}
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{
if ($background === null) {
return false;
}
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
return false;
}
return $slide->cover_mode !== true;
}
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): string
{
if ($item->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
return 'sermon';
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns === null) {
return 'agenda_item';
}
$patterns = array_map('trim', explode(',', $sermonPatterns));
return app(AgendaMatcherService::class)->matchesAny($item->title, $patterns)
? 'sermon'
: 'agenda_item';
}
private function isNameTagAgendaItem(ServiceAgendaItem $item): bool
{
$title = mb_strtolower($item->title ?? '');
$type = mb_strtolower($item->type ?? '');
return str_contains($title, 'nametag')
|| str_contains($title, 'namenseinblender')
|| str_contains($type, 'nametag')
|| str_contains($type, 'namenseinblender');
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -4,6 +4,8 @@
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Models\Slide;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use ProPresenter\Parser\PresentationBundle;
@ -15,6 +17,11 @@ class ProBundleExportService
{
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
) {}
public function generateBundle(Service $service, string $blockType): string
{
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
@ -28,15 +35,16 @@ public function generateBundle(Service $service, string $blockType): string
$groupName = ucfirst($blockType);
return $this->buildBundleFromSlides($slides, $groupName);
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
}
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
{
$agendaItem->loadMissing([
'service',
'slides',
'serviceSong.song.groups.slides',
'serviceSong.song.arrangements.arrangementGroups.group',
'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.song.arrangements.arrangementSections.section.label',
]);
$title = $agendaItem->title ?: 'Ablauf-Element';
@ -44,14 +52,22 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
$song = $agendaItem->serviceSong->song;
if ($song->groups()->count() === 0) {
$labelCount = $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
if ($labelCount === 0) {
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
}
$parserSong = (new ProExportService)->generateParserSong($song);
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
$proFilename = self::safeFilename($song->title).'.pro';
$bundle = new PresentationBundle($parserSong, $proFilename);
$songMediaFiles = [];
$this->embedBackground($agendaItem->service, $songMediaFiles);
$bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles);
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
ProBundleWriter::write($bundle, $bundlePath);
@ -63,14 +79,27 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
->orderBy('sort_order')
->get();
return $this->buildBundleFromSlides($slides, $title);
return $this->buildBundleFromSlides(
$slides,
$title,
$agendaItem->service,
'agenda_item',
$this->backgroundPartTypeForAgendaItem($agendaItem),
);
}
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
private function buildBundleFromSlides($slides, string $groupName): string
{
private function buildBundleFromSlides(
$slides,
string $groupName,
?Service $service = null,
?string $partType = null,
?string $backgroundPartType = null,
): string {
$slideData = [];
$mediaFiles = [];
$background = $this->backgroundData($service);
$backgroundAttached = false;
foreach ($slides as $slide) {
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
@ -86,11 +115,37 @@ private function buildBundleFromSlides($slides, string $groupName): string
$mediaFiles[$imageFilename] = $imageContent;
$slideData[] = [
$singleSlideData = [
'media' => $imageFilename,
'format' => 'JPG',
'label' => $slide->original_filename,
];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background;
$backgroundAttached = true;
}
if ($service !== null && $partType !== null) {
$slideIndex = count($slideData);
$totalSlides = $slides->count();
$macros = $this->macroResolutionService->macrosForSlide(
$service,
$partType,
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => null],
);
if (! empty($macros)) {
// ProPresenter parser currently supports one `macro` entry per slide
$singleSlideData['macro'] = $macros[0];
}
}
$slideData[] = $singleSlideData;
}
if ($backgroundAttached) {
$this->embedBackground($service, $mediaFiles);
}
$groups = [
@ -118,6 +173,83 @@ private function buildBundleFromSlides($slides, string $groupName): string
return $bundlePath;
}
private function backgroundData(?Service $service): ?array
{
if ($this->backgroundSourcePath($service) === null) {
return null;
}
return [
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
/** Absolute filesystem path of the resolved background image, or null. */
private function backgroundSourcePath(?Service $service): ?string
{
if ($service === null) {
return null;
}
$background = $this->imageResolver->backgroundFor($service);
if ($background === null || ! Storage::disk('public')->exists($background)) {
return null;
}
return Storage::disk('public')->path($background);
}
/** Embed the resolved background image bytes into the bundle under the fixed export name. */
private function embedBackground(?Service $service, array &$mediaFiles): void
{
$sourcePath = $this->backgroundSourcePath($service);
if ($sourcePath === null || isset($mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
return;
}
$contents = @file_get_contents($sourcePath);
if ($contents !== false) {
$mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
}
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{
if ($background === null) {
return false;
}
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
return false;
}
return $slide->cover_mode !== true;
}
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $agendaItem): string
{
if ($agendaItem->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
return 'sermon';
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns === null) {
return 'agenda_item';
}
$patterns = array_map('trim', explode(',', $sermonPatterns));
return app(AgendaMatcherService::class)->matchesAny($agendaItem->title, $patterns)
? 'sermon'
: 'agenda_item';
}
private static function safeFilename(string $name): string
{
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';

View file

@ -2,20 +2,25 @@
namespace App\Services;
use App\Models\Setting;
use App\Models\Service;
use App\Models\Song;
use ProPresenter\Parser\ProFileGenerator;
class ProExportService
{
public function generateProFile(Song $song): string
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
) {}
public function generateProFile(Song $song, ?Service $service = null): string
{
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
ProFileGenerator::generateAndWrite(
$tempPath,
$song->title,
$this->buildGroups($song),
$this->buildGroups($song, $service),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
@ -23,44 +28,79 @@ public function generateProFile(Song $song): string
return $tempPath;
}
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
{
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
return ProFileGenerator::generate(
$song->title,
$this->buildGroups($song),
$this->buildGroups($song, $service),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
}
private function buildGroups(Song $song): array
private function buildGroups(Song $song, ?Service $service = null): array
{
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
if ($defaultArr === null) {
return [];
}
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
$groups = [];
$macroData = $this->buildMacroData();
$seenSectionIds = [];
$background = $this->backgroundData($service);
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
$label = $section?->label;
if ($section === null || $label === null) {
continue;
}
if (in_array($section->id, $seenSectionIds, true)) {
continue;
}
$seenSectionIds[] = $section->id;
foreach ($song->groups->sortBy('order') as $group) {
$slides = [];
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
$sectionSlides = $section->slides->sortBy('order')->values();
$totalSlides = $sectionSlides->count();
foreach ($group->slides->sortBy('order') as $slide) {
foreach ($sectionSlides as $slideIndex => $slide) {
$slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) {
$slideData['translation'] = $slide->text_content_translated;
}
if ($isCopyrightGroup && $macroData) {
$slideData['macro'] = $macroData;
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
$slideData['background'] = $background;
}
if ($service !== null) {
$macros = $this->macroResolutionService->macrosForSlide(
$service,
'song',
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id],
);
if (! empty($macros)) {
// ProPresenter parser currently supports one `macro` entry per slide; keep the first resolved macro until stacked macros are supported.
$slideData['macro'] = $macros[0];
}
}
$slides[] = $slideData;
}
$groups[] = [
'name' => $group->name,
'color' => ProImportService::hexToRgba($group->color),
'name' => $label->name,
'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
'slides' => $slides,
];
}
@ -68,32 +108,46 @@ private function buildGroups(Song $song): array
return $groups;
}
private function buildMacroData(): ?array
private function backgroundData(?Service $service): ?array
{
$name = Setting::get('macro_name');
$uuid = Setting::get('macro_uuid');
if ($service === null) {
return null;
}
if (! $name || ! $uuid) {
$background = $this->imageResolver->backgroundFor($service);
if ($background === null) {
return null;
}
return [
'name' => $name,
'uuid' => $uuid,
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
'bundleRelative' => true,
];
}
private function isFullCoverImageSlide(object $slide, array $slideData): bool
{
if (! isset($slideData['media'])) {
return false;
}
return ($slide->cover_mode ?? null) === true;
}
private function buildArrangements(Song $song): array
{
$arrangements = [];
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
foreach ($song->arrangements as $arrangement) {
$groupNames = $arrangement->arrangementGroups
$arrangement->loadMissing('arrangementSections.section.label');
$groupNames = $arrangement->arrangementSections
->sortBy('order')
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null)
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
->filter()
->values()
->toArray();

View file

@ -2,10 +2,12 @@
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Support\MacroColorConverter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\ProFileReader;
@ -103,28 +105,35 @@ private function upsertSong(ProSong $proSong): Song
}
$song->arrangements()->each(function (SongArrangement $arr) {
$arr->arrangementGroups()->delete();
$arr->arrangementSections()->delete();
});
$song->arrangements()->delete();
$song->groups()->each(function (SongGroup $group) {
$group->slides()->delete();
});
$song->groups()->delete();
$hasTranslation = false;
$groupMap = [];
$sectionsByName = [];
foreach ($proSong->getGroups() as $position => $proGroup) {
foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
$groupName = $proGroup->getName();
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
if ($existingLabel === null) {
$color = $proGroup->getColor();
$hexColor = $color ? self::rgbaToHex($color) : '#808080';
$hexColor = MacroColorConverter::fromRgba($color);
$songGroup = $song->groups()->create([
'name' => $proGroup->getName(),
$existingLabel = Label::create([
'name' => $groupName,
'color' => $hexColor,
'order' => $position,
]);
}
$groupMap[$proGroup->getName()] = $songGroup;
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $existingLabel->id],
['order' => $groupOrder + 1],
);
$section->update(['order' => $groupOrder + 1]);
$sectionsByName[$groupName] = $section;
$section->slides()->delete();
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
$translatedText = null;
@ -134,7 +143,7 @@ private function upsertSong(ProSong $proSong): Song
$hasTranslation = true;
}
$songGroup->slides()->create([
$section->slides()->create([
'order' => $slidePosition,
'text_content' => $proSlide->getPlainText(),
'text_content_translated' => $translatedText,
@ -153,19 +162,19 @@ private function upsertSong(ProSong $proSong): Song
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
foreach ($groupsInArrangement as $order => $proGroup) {
$songGroup = $groupMap[$proGroup->getName()] ?? null;
$section = $sectionsByName[$proGroup->getName()] ?? null;
if ($songGroup) {
SongArrangementGroup::create([
if ($section) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroup->id,
'song_section_id' => $section->id,
'order' => $order,
]);
}
}
}
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']);
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
}
public static function rgbaToHex(array $rgba): string

View file

@ -0,0 +1,47 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\Setting;
use Illuminate\Support\Facades\Storage;
class ServiceImageResolver
{
/**
* Fixed export filename for the key-visual image. The image bytes are
* embedded under this name AND referenced by this name inside the .pro file.
*/
public const KEY_VISUAL_EXPORT_NAME = 'KEY_VISUAL.jpg';
/**
* Fixed export filename for the background image. The image bytes are
* embedded under this name AND referenced by this name inside the .pro file.
*/
public const BACKGROUND_EXPORT_NAME = 'BACKGROUND.jpg';
public function keyVisualFor(Service $service): ?string
{
return $this->resolve($service->key_visual_filename, 'current_key_visual');
}
public function backgroundFor(Service $service): ?string
{
return $this->resolve($service->background_filename, 'current_background');
}
private function resolve(?string $serviceFilename, string $settingKey): ?string
{
if ($serviceFilename !== null && Storage::disk('public')->exists($serviceFilename)) {
return $serviceFilename;
}
$globalFilename = Setting::get($settingKey);
if ($globalFilename !== null && Storage::disk('public')->exists($globalFilename)) {
return $globalFilename;
}
return null;
}
}

View file

@ -2,36 +2,55 @@
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class SongService
{
/**
* Default-Gruppen für ein neues Lied erstellen.
* Sicherstellen, dass die Default-Labels und Song-Sektionen existieren.
*
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
* @return Collection<int, SongSection>
*/
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
public function createDefaultGroups(Song $song): Collection
{
$defaults = [
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
['name' => 'Strophe 1', 'color' => '#3B82F6'],
['name' => 'Refrain', 'color' => '#10B981'],
['name' => 'Bridge', 'color' => '#F59E0B'],
];
foreach ($defaults as $groupData) {
$song->groups()->create($groupData);
$sections = collect();
foreach ($defaults as $index => $data) {
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
if ($existing === null) {
$existing = Label::create([
'name' => $data['name'],
'color' => $data['color'],
]);
}
return $song->groups()->orderBy('order')->get();
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $existing->id],
['order' => $index + 1],
);
$section->update(['order' => $index + 1]);
$sections->push($section);
}
return $sections;
}
/**
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
*/
public function createDefaultArrangement(Song $song): SongArrangement
{
@ -40,16 +59,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
'is_default' => true,
]);
$groups = $song->groups()->orderBy('order')->get();
$sections = $this->createDefaultGroups($song);
foreach ($groups as $index => $group) {
$arrangement->arrangementGroups()->create([
'song_group_id' => $group->id,
foreach ($sections->values() as $index => $section) {
$arrangement->arrangementSections()->create([
'song_section_id' => $section->id,
'order' => $index + 1,
]);
}
return $arrangement->load('arrangementGroups');
return $arrangement->load('arrangementSections.section.label');
}
/**
@ -63,15 +82,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
$clone->is_default = false;
$clone->save();
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
SongArrangementGroup::create([
foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
SongArrangementLabel::create([
'song_arrangement_id' => $clone->id,
'song_group_id' => $group->song_group_id,
'order' => $group->order,
'song_section_id' => $arrangementSection->song_section_id,
'order' => $arrangementSection->order,
]);
}
return $clone->load('arrangementGroups');
return $clone->load('arrangementSections.section.label');
});
}
}

View file

@ -8,12 +8,6 @@
class TranslationService
{
/**
* Text von einer URL abrufen (Best-Effort).
*
* HTML-Tags werden entfernt, nur reiner Text zurückgegeben.
* Bei Fehlern wird null zurückgegeben, ohne Exception.
*/
public function fetchFromUrl(string $url): ?string
{
try {
@ -33,29 +27,30 @@ public function fetchFromUrl(string $url): ?string
return null;
}
/**
* Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
*
* Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
* Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
*
* Beispiel:
* Slide 1 hat 4 Zeilen bekommt die nächsten 4 Zeilen der Übersetzung
* Slide 2 hat 2 Zeilen bekommt die nächsten 2 Zeilen
* Slide 3 hat 4 Zeilen bekommt die nächsten 4 Zeilen
*/
public function importTranslation(Song $song, string $text): void
{
$translatedLines = explode("\n", $text);
$offset = 0;
// Alle Gruppen nach order sortiert laden, mit Slides
$groups = $song->groups()->orderBy('order')->with([
'slides' => fn ($query) => $query->orderBy('order'),
])->get();
$defaultArr = $song->arrangements()
->where('is_default', true)
->with(['arrangementSections' => fn ($q) => $q->orderBy('order'), 'arrangementSections.section.slides'])
->first();
foreach ($groups as $group) {
foreach ($group->slides as $slide) {
if ($defaultArr === null) {
$this->markAsTranslated($song);
return;
}
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null) {
continue;
}
foreach ($section->slides->sortBy('order') as $slide) {
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
$offset += $originalLineCount;
@ -69,30 +64,23 @@ public function importTranslation(Song $song, string $text): void
$this->markAsTranslated($song);
}
/**
* Song als "hat Übersetzung" markieren.
*/
public function markAsTranslated(Song $song): void
{
$song->update(['has_translation' => true]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*
* Löscht alle text_content_translated Felder und setzt has_translation auf false.
*/
public function removeTranslation(Song $song): void
{
// Alle Slides des Songs über die Gruppen aktualisieren
$slideIds = SongSlide::whereIn(
'song_group_id',
$song->groups()->pluck('id')
)->pluck('id');
$sectionIds = $song->sections()
->pluck('id')
->unique()
->values();
SongSlide::whereIn('id', $slideIds)->update([
if ($sectionIds->isNotEmpty()) {
SongSlide::whereIn('song_section_id', $sectionIds)->update([
'text_content_translated' => null,
]);
}
$song->update(['has_translation' => false]);
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Support;
final class CcliLabels
{
/**
* Regex matching CCLI SongSelect section labels (English + German + variants).
*/
public const SECTION_LABEL_PATTERN = '/^(Verse|Vers|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 = [
'Vers' => 'Verse',
'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|Vers|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|Vers|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

@ -0,0 +1,20 @@
<?php
namespace App\Support;
final class MacroColorConverter
{
public static function fromRgba(?array $rgba): ?string
{
if ($rgba === null) {
return null;
}
return sprintf(
'#%02X%02X%02X',
(int) round(max(0.0, min(1.0, $rgba['r'])) * 255),
(int) round(max(0.0, min(1.0, $rgba['g'])) * 255),
(int) round(max(0.0, min(1.0, $rgba['b'])) * 255),
);
}
}

View file

@ -52,6 +52,10 @@ 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
@ -74,12 +78,6 @@ 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)
@ -133,7 +131,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, builds Vite assets, runs migrations,
# warms caches, then exec's supervisord (CMD).
# creates DB on first run, syncs pre-built Vite assets from /app/public-build/,
# 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] Building Vite assets..."
npm run build
echo "[boot] Syncing pre-built Vite assets to bind-mounted public/ ..."
cp -r /app/public-build/* /app/public/ 2>/dev/null || true
# 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,6 +13,7 @@ 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

@ -15,7 +15,7 @@
}
],
"require": {
"php": "^8.2",
"php": "^8.4",
"5pm-hdh/churchtools-api": "^2.1",
"barryvdh/laravel-dompdf": "^3.1",
"inertiajs/inertia-laravel": "^2.0",
@ -33,7 +33,6 @@
"laravel/breeze": "^2.3",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.4",

Some files were not shown because too many files have changed in this diff Show more