Compare commits
No commits in common. "master" and "dev-with-valet" have entirely different histories.
master
...
dev-with-v
|
|
@ -1,17 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
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>
|
|
||||||
# It’s unusual to change this option, and we don’t 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 don’t intend to use the https_port!
|
|
||||||
# If you don’t 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:
|
|
||||||
|
|
@ -2,7 +2,7 @@ APP_NAME="CTS Presenter"
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://pp-planer.ddev.site
|
APP_URL=http://cts-work.test
|
||||||
|
|
||||||
# Application Locale (German)
|
# Application Locale (German)
|
||||||
APP_LOCALE=de
|
APP_LOCALE=de
|
||||||
|
|
@ -74,7 +74,7 @@ CTS_API_TOKEN=CHANGEME
|
||||||
CHURCHTOOLS_URL=https://CHANGEME.church.tools
|
CHURCHTOOLS_URL=https://CHANGEME.church.tools
|
||||||
CHURCHTOOLS_CLIENT_ID=CHANGEME
|
CHURCHTOOLS_CLIENT_ID=CHANGEME
|
||||||
CHURCHTOOLS_CLIENT_SECRET=CHANGEME
|
CHURCHTOOLS_CLIENT_SECRET=CHANGEME
|
||||||
CHURCHTOOLS_REDIRECT_URI=https://pp-planer.ddev.site/auth/churchtools/callback
|
CHURCHTOOLS_REDIRECT_URI=http://cts-work.test/auth/churchtools/callback
|
||||||
|
|
||||||
# File Upload Configuration
|
# File Upload Configuration
|
||||||
# Maximum file size in bytes (default: 100MB)
|
# Maximum file size in bytes (default: 100MB)
|
||||||
|
|
@ -85,7 +85,6 @@ UPLOAD_TEMP_DIR=/tmp
|
||||||
TEST_CTS_USERNAME=
|
TEST_CTS_USERNAME=
|
||||||
TEST_CTS_PASSWORD=
|
TEST_CTS_PASSWORD=
|
||||||
|
|
||||||
# Production Docker only: map FPM worker to host user (run `id -u` and `id -g`).
|
# Docker: map FPM worker to host user (run `id -u` and `id -g`)
|
||||||
# Not used by DDEV local dev.
|
|
||||||
WWWUSER=1000
|
WWWUSER=1000
|
||||||
WWWGROUP=1000
|
WWWGROUP=1000
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,4 +27,3 @@ Thumbs.db
|
||||||
tests/e2e/.auth/
|
tests/e2e/.auth/
|
||||||
test-results/
|
test-results/
|
||||||
.dev.pid
|
.dev.pid
|
||||||
.dev.log
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
ls tests/fixtures/ccli/*.txt | wc -l
|
|
||||||
22
|
|
||||||
|
|
||||||
grep -rl "Strophe\|Refrain" tests/fixtures/ccli/ | wc -l
|
|
||||||
4
|
|
||||||
|
|
||||||
grep -rl "(Repeat)" tests/fixtures/ccli/ | wc -l
|
|
||||||
2
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
ddev exec php artisan test --filter=CcliFixtureSanityTest
|
|
||||||
|
|
||||||
PASS Tests\Feature\CcliFixtureSanityTest
|
|
||||||
✓ ccli fixture corpus has at least 20 txt files
|
|
||||||
✓ each ccli fixture is valid utf8 with section labels and title
|
|
||||||
✓ ccli fixture corpus covers german labels
|
|
||||||
✓ ccli fixture corpus covers repeat markers
|
|
||||||
|
|
||||||
Tests: 4 passed (160 assertions)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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`
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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).
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
|
|
||||||
PASS Tests\Feature\FileConversionServiceTest
|
|
||||||
✓ contain conversion keeps black bars and fullCover false 0.89s
|
|
||||||
|
|
||||||
Tests: 1 passed (12 assertions)
|
|
||||||
Duration: 1.06s
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
Command: ddev exec php artisan test --filter=CcliLabelsTest
|
|
||||||
Result: PASS
|
|
||||||
Assertions: 71
|
|
||||||
Tests: 55 passed
|
|
||||||
|
|
||||||
Command: ddev exec ./vendor/bin/pint --test app/Support/CcliLabels.php tests/Unit/CcliLabelsTest.php
|
|
||||||
Result: PASS
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
Command: ddev exec php artisan tinker --execute="var_export([App\\Support\\CcliLabels::normalizeLabelName('Foobar'), App\\Support\\CcliLabels::normalizeLabelName('')]);"
|
|
||||||
Result:
|
|
||||||
array (
|
|
||||||
0 => 'Foobar',
|
|
||||||
1 => '',
|
|
||||||
)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
CAUGHT: Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
array:3 [
|
|
||||||
"title" => "Test Song 3"
|
|
||||||
"sections" => 2
|
|
||||||
"has_translation" => true
|
|
||||||
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:4
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
array:2 [
|
|
||||||
"repeat_sections" => 1
|
|
||||||
"modifier" => "Repeat"
|
|
||||||
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:3
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
array:2 [
|
|
||||||
"title" => "Test Song 15"
|
|
||||||
"has_umlauts" => true
|
|
||||||
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:3
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
Command: ddev exec php artisan test --filter='blocks active duplicate ccli_id with DuplicateCcliSongException'
|
|
||||||
|
|
||||||
PASS: Active duplicate CCLI-ID is blocked.
|
|
||||||
|
|
||||||
Verified assertions:
|
|
||||||
- first import succeeds
|
|
||||||
- second import throws App\Exceptions\DuplicateCcliSongException
|
|
||||||
- exception existingSongId matches original Song ID
|
|
||||||
- exception message contains "existiert bereits"
|
|
||||||
- Song::count() remains 1
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
Command: ddev exec php artisan test --filter='imports english-only fixture and creates song with default arrangement'
|
|
||||||
|
|
||||||
PASS: CcliImportService imported tests/fixtures/ccli/english-only-multi-verse.txt
|
|
||||||
|
|
||||||
Verified assertions:
|
|
||||||
- status = created
|
|
||||||
- Song title = Test Song 1
|
|
||||||
- Song ccli_id = 9999001
|
|
||||||
- imported_from_ccli_at is set
|
|
||||||
- ccli_source_url = https://songselect.ccli.com/Songs/9999001
|
|
||||||
- default arrangement name = normal, is_default = true
|
|
||||||
- arrangement label entries = 5
|
|
||||||
- slide rows = 9
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
Command: ddev exec php artisan test --filter='restores soft-deleted song and does not duplicate normal arrangement'
|
|
||||||
|
|
||||||
PASS: Soft-deleted CCLI match is restored instead of duplicated.
|
|
||||||
|
|
||||||
Verified assertions:
|
|
||||||
- restored import status = restored
|
|
||||||
- returned Song ID matches original soft-deleted Song ID
|
|
||||||
- restored song is no longer trashed
|
|
||||||
- only one normal arrangement exists after restore/import
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
Command: ddev exec php artisan test --filter='rolls back song and log when slide creation fails'
|
|
||||||
|
|
||||||
PASS: Transaction rollback verified via SQLite trigger failure on song_slides insert.
|
|
||||||
|
|
||||||
Verified assertions:
|
|
||||||
- import throws Illuminate\Database\QueryException
|
|
||||||
- Song::count() = 0 after failure
|
|
||||||
- SongSlide::count() = 0 after failure
|
|
||||||
- ApiRequestLog::count() = 0 after failure
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
Task 8 evidence: German local labels pair with English CCLI labels.
|
|
||||||
|
|
||||||
Command run:
|
|
||||||
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
|
|
||||||
|
|
||||||
Relevant passing test:
|
|
||||||
✓ pairs German local labels with English CCLI labels via normalization
|
|
||||||
|
|
||||||
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
|
|
||||||
- Local "Strophe 1" normalizes to canonical "verse 1" and matches CCLI "Verse 1".
|
|
||||||
- Local "Refrain" normalizes to canonical "chorus" and matches CCLI "Chorus".
|
|
||||||
- unmatched_labels is empty.
|
|
||||||
- mapping has three entries.
|
|
||||||
|
|
||||||
Full targeted result:
|
|
||||||
Tests: 5 passed (20 assertions)
|
|
||||||
Duration: 0.46s
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
Task 8 evidence: unmatched local sections are returned for UI review.
|
|
||||||
|
|
||||||
Command run:
|
|
||||||
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
|
|
||||||
|
|
||||||
Relevant passing test:
|
|
||||||
✓ returns unmatched_labels for sections not in CCLI
|
|
||||||
|
|
||||||
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
|
|
||||||
- Local arrangement contains Verse 1, Chorus, Bridge.
|
|
||||||
- CCLI paste contains Verse 1 and Chorus only.
|
|
||||||
- result['unmatched_labels'] contains "Bridge".
|
|
||||||
- mapping still has all three local labels.
|
|
||||||
- Bridge mapping has ccli_label = null and empty distributed line placeholders.
|
|
||||||
|
|
||||||
Full targeted result:
|
|
||||||
Tests: 5 passed (20 assertions)
|
|
||||||
Duration: 0.46s
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# CCLI SongSelect Import — Issues
|
|
||||||
|
|
||||||
## [2026-05-10] Known Issues / Gotchas
|
|
||||||
|
|
||||||
### T1: Fixture corpus requires structurally accurate CCLI format
|
|
||||||
- Plan originally asked for "real CCLI text pastes" but since we can't access SongSelect, agent creates synthetic fixtures.
|
|
||||||
- Synthetic fixtures MUST match exact CCLI format: title line, blank, section label, lyrics, blank, footer with © and CCLI #.
|
|
||||||
- The parser tests depend on these fixtures — structural accuracy is critical.
|
|
||||||
|
|
||||||
### T7: ProImportService method name
|
|
||||||
- Plan was corrected: the public method is `ProImportService::import(UploadedFile $file)`, not `upsertSong()`.
|
|
||||||
- When mirroring the pattern, READ app/Services/ProImportService.php before implementing.
|
|
||||||
|
|
||||||
### No Admin Role
|
|
||||||
- No `admin` role or Policy exists in the codebase.
|
|
||||||
- CCLI Settings section is visible to ALL authenticated users.
|
|
||||||
- Document this decision, don't create a policy gate.
|
|
||||||
|
|
||||||
### AGENDA_KEYS whitelist
|
|
||||||
- SettingsController has a `const AGENDA_KEYS` array.
|
|
||||||
- T4 MUST add `'default_translation_language'` to this array OR update the validation to include it.
|
|
||||||
- Failure to update AGENDA_KEYS = PATCH /settings will silently ignore the new key.
|
|
||||||
|
|
||||||
### Translation Pairing Label Direction
|
|
||||||
- CCLI paste can have English labels; local songs may have German labels (Strophe, Refrain).
|
|
||||||
- CcliLabels::normalizeLabelName() normalizes BOTH directions to canonical English before pairing.
|
|
||||||
- Do NOT assume same language on both sides.
|
|
||||||
|
|
||||||
### T7: Global Label Slide Replacement Caveat
|
|
||||||
- The requested CCLI import pattern deletes `songSlides()` on the resolved global `Label` before recreating slides.
|
|
||||||
- Because labels are shared globally, a later import using the same canonical label name replaces that label's slide text globally; this intentionally matches the task spec and existing `ProImportService` pattern, but remains a design caveat for future per-song slide ownership work.
|
|
||||||
|
|
||||||
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
|
|
||||||
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
|
|
||||||
- Missing labels are returned in `unmatched_labels`, and their mapping entries keep empty slide placeholders so `distributed_text` still aligns with the local arrangement shape.
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
# CCLI SongSelect Import — Learnings
|
|
||||||
|
|
||||||
## [2026-05-10] Session Start
|
|
||||||
|
|
||||||
### Architecture Decisions
|
|
||||||
- Manual paste + bookmarklet approach (NO server-side scraping — Cloudflare/ToS blocker)
|
|
||||||
- CcliPasteParser is closure-injectable (mirrors ChurchToolsService pattern for testability)
|
|
||||||
- All songs upserted via CcliImportService mirroring ProImportService::import() shape
|
|
||||||
- Translation stored inline on SongSlide.text_content_translated (no separate model)
|
|
||||||
- default_translation_language = APP-GLOBAL Setting (not per-user)
|
|
||||||
|
|
||||||
### Key Codebase Facts
|
|
||||||
- Song.ccli_id is UNIQUE indexed nullable — primary CCLI match key
|
|
||||||
- SongSlide: text_content (original), text_content_translated (translation)
|
|
||||||
- Labels are GLOBAL (shared across all songs) — labels table with name + color
|
|
||||||
- ProImportService::import() is the template for upsert (not upsertSong — that method doesn't exist)
|
|
||||||
- SettingsController::AGENDA_KEYS constant whitelist for Settings KV
|
|
||||||
- TranslationService::importFromText distributes lines preserving local slide line counts
|
|
||||||
- ArrangementDialog.vue lines 488-532 = searchable song select (where CCLI buttons go)
|
|
||||||
|
|
||||||
### CCLI SongSelect "View Lyrics" Page Format
|
|
||||||
- Title on first non-empty line
|
|
||||||
- Section label as standalone line (e.g., "Verse 1", "Chorus")
|
|
||||||
- Lyrics lines under each section
|
|
||||||
- Footer: copyright (©), CCLI number (e.g., "CCLI # 1234567"), author
|
|
||||||
|
|
||||||
### Section Label Regex (English + German + variants)
|
|
||||||
```
|
|
||||||
/^(Verse|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?(\s*\((?:Repeat|Wdh\.?)\))?(\s*[xX]\s*\d+)?$/i
|
|
||||||
```
|
|
||||||
|
|
||||||
### Language Mapping (EN ↔ DE)
|
|
||||||
- Verse ↔ Strophe
|
|
||||||
- Chorus ↔ Refrain
|
|
||||||
- Bridge ↔ Brücke
|
|
||||||
- Pre-Chorus ↔ Vorrefrain
|
|
||||||
- Ending ↔ Schluss
|
|
||||||
- Interlude ↔ Zwischenspiel
|
|
||||||
|
|
||||||
### Test Fixture Format
|
|
||||||
Fixtures are synthetic CCLI-format text files. Format:
|
|
||||||
```
|
|
||||||
Test Song Title
|
|
||||||
Test Artist
|
|
||||||
|
|
||||||
Verse 1
|
|
||||||
Line 1 of verse
|
|
||||||
Line 2 of verse
|
|
||||||
|
|
||||||
Chorus
|
|
||||||
Chorus line 1
|
|
||||||
Chorus line 2
|
|
||||||
|
|
||||||
© 2024 Test Publishing
|
|
||||||
CCLI # 9999001
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fixture Corpus Notes
|
|
||||||
- Keep fixture titles/artists anonymized and numeric (`Test Song N`, `Test Artist N`)
|
|
||||||
- Include both English and German section labels in the corpus so parser regex coverage stays broad
|
|
||||||
- Add edge cases for missing footer pieces, whitespace, repeat markers, and suffix labels (`2a`, `x2`, `(Repeat)`)
|
|
||||||
|
|
||||||
### 2026-05-10 CCLI Label Utility Notes
|
|
||||||
- `CcliLabels` works best with a fixed kind list in regexes; no locale config needed for EN/DE normalization.
|
|
||||||
- `normalizeLabelName()` should map only known German kinds and preserve any numeric suffix.
|
|
||||||
- `parseLabel()` can stay lightweight by returning `null` for non-labels and a small array for matched labels.
|
|
||||||
|
|
||||||
### 2026-05-10 Song CCLI Metadata Migration
|
|
||||||
- Song CCLI metadata belongs on `songs` as nullable fields: `imported_from_ccli_at` (timestamp) + `ccli_source_url` (string 500).
|
|
||||||
- Factory state helpers can stay tiny; `fromCcli()` just seeds timestamp + SongSelect URL.
|
|
||||||
- Inference/LSP can lag after edits; a tiny no-op signature change (`fn (): array => [...]`) forced the factory diagnostics to refresh cleanly.
|
|
||||||
|
|
||||||
### 2026-05-10 Settings Language Seed
|
|
||||||
- `SettingsController::AGENDA_KEYS` drives both the index props and the allowed `key` values for PATCH updates.
|
|
||||||
- `default_translation_language` should be validated as a whitelist value (`DE|EN|FR|ES|NL|IT`) only when that setting is being updated.
|
|
||||||
- `CcliSettingsSeeder` must use `Setting::firstOrCreate()` so reseeding does not overwrite a user-changed language.
|
|
||||||
|
|
||||||
### 2026-05-10 CcliPasteParser Scaffold
|
|
||||||
- Mirror `ChurchToolsService` with nullable `Closure` constructor injections and default `= null` values.
|
|
||||||
- This codebase uses `App\Services\DTO\...` namespaces/directories for DTOs, so keep the uppercase `DTO` path aligned with existing services.
|
|
||||||
- Scaffold tests can verify Laravel container resolution without adding any service provider binding.
|
|
||||||
|
|
||||||
### 2026-05-10 CcliPasteParser Implementation
|
|
||||||
- Parser trims pasted lines, treats blank lines as separators, extracts first two header lines as title/author, and excludes CCLI metadata from lyric sections.
|
|
||||||
- EN/DE side-by-side imports merge only adjacent labels with different raw kinds but the same `CcliLabels::normalizeLabelName()` canonical kind/number, preserving German lyrics in `linesTranslated`.
|
|
||||||
- DDEV/Linux path is `tests/fixtures/ccli` (lowercase); macOS accepted `tests/Fixtures/ccli`, but tests must use lowercase for container portability.
|
|
||||||
|
|
||||||
### 2026-05-10 CcliImportService Implementation
|
|
||||||
- `CcliImportService` mirrors `ProImportService` by wrapping song metadata, global label resolution, slide replacement, default arrangement upsert, arrangement-label recreation, and `ApiRequestLog` success entry in one `DB::transaction()`.
|
|
||||||
- Active duplicate CCLI IDs are blocked with `DuplicateCcliSongException`; trashed matches are restored and updated in-place via `Song::withTrashed()->where('ccli_id', ...)`.
|
|
||||||
- CCLI label names should be canonicalized with `CcliLabels::normalizeLabelName($kind.' '.$number)` before `Label::firstOrCreate()`, keeping labels global/shared.
|
|
||||||
- Import tests can verify rollback deterministically with a temporary SQLite trigger that aborts `song_slides` insert; this proves song + log rows are not persisted after mid-transaction failure.
|
|
||||||
|
|
||||||
### 2026-05-10 CCLI Parser Review Fixes
|
|
||||||
- CCLI SongSelect metadata can appear as `CCLI Song #`, `CCLI-Nr.` or `CCLI-Liednummer`; extraction must ignore `CCLI License/Lizenz` numbers.
|
|
||||||
- Parsed section `kind` is canonicalized via `CcliLabels::normalizeLabelName()`, while the original pasted label remains available in `label`; translation pairing still compares raw label kinds internally.
|
|
||||||
|
|
||||||
### 2026-05-10 CCLI Translation Pairing
|
|
||||||
- `CcliTranslationPairingService` returns a review-only mapping and never writes `SongSlide.text_content_translated`; callers remain responsible for persistence.
|
|
||||||
- Pairing canonicalizes both local arrangement labels and CCLI sections with `CcliLabels::normalizeLabelName()` + lowercase, so `Strophe 1` ↔ `Verse 1` and `Refrain` ↔ `Chorus` work across languages.
|
|
||||||
- Distribution mirrors `TranslationService::importTranslation()` by filling local slide slots in arrangement order using each local slide's original line count; overflow CCLI lines are kept on the final local slide for that section.
|
|
||||||
|
|
||||||
### 2026-05-11 CcliPasteController (T10)
|
|
||||||
- `SongMatchingService::manualAssign(ServiceSong, Song)` takes a **Song object** (not int id) — different from initial task plan.
|
|
||||||
- API routes use `auth:sanctum` middleware (not just `auth`); Sanctum's `EnsureFrontendRequestsAreStateful` is prepended globally to API in bootstrap/app.php.
|
|
||||||
- Apply `throttle:30,1` via a nested `Route::middleware('throttle:30,1')->group(...)` inside the existing sanctum group; combined middleware shown by `route:list -vv`.
|
|
||||||
- `assertInertia()` enforces page-component file existence by default. For pages whose Vue component is created in a later task (T16 `Songs/ImportFromCcliPaste`), pass `$shouldExist=false` to `component()` as second arg.
|
|
||||||
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
|
|
||||||
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
|
|
||||||
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
- 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: 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: `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-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
|
## [2026-03-01] Wave 2 Complete — T8-T13
|
||||||
|
|
||||||
|
|
@ -351,5 +350,3 @@ ### Verification Success Criteria Met
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- Task 2 will likely involve testing OAuth login flow with ChurchTools
|
- Task 2 will likely involve testing OAuth login flow with ChurchTools
|
||||||
- May need to configure CTS_API_TOKEN and CHURCHTOOLS credentials for full testing
|
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
# 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
194
AGENTS.md
|
|
@ -98,145 +98,6 @@ ## SongDB Import
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CCLI Import
|
|
||||||
|
|
||||||
Songs can be imported from CCLI SongSelect via a paste-based flow (no server-side scraping — ToS-compliant).
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
|
|
||||||
1. User opens a song on **songselect.ccli.com** in their browser (must be logged in with their CCL account)
|
|
||||||
2. User copies the full page text (Ctrl+A → Ctrl+C)
|
|
||||||
3. User clicks "Aus CCLI importieren" in the SongDB or service form, pastes the text, clicks "Vorschau", then "Importieren"
|
|
||||||
|
|
||||||
Alternatively, install the **browser bookmarklet** from Settings → CCLI Import. The bookmarklet automates step 2-3 in two clicks.
|
|
||||||
|
|
||||||
### Key files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `app/Services/CcliPasteParser.php` | Pure-PHP parser: extracts title/author/CCLI-Nr/year/sections from pasted text |
|
|
||||||
| `app/Services/CcliImportService.php` | Upserts Song + Arrangement + Labels + Slides; handles duplicates and soft-delete restore |
|
|
||||||
| `app/Services/CcliTranslationPairingService.php` | Auto-pairs CCLI sections with local song labels for translation import |
|
|
||||||
| `app/Support/CcliLabels.php` | Section-label regex + EN↔DE name mapping (Verse↔Strophe, Chorus↔Refrain, etc.) |
|
|
||||||
| `app/Http/Controllers/CcliPasteController.php` | POST /api/ccli/preview + POST /api/songs/import-from-ccli-paste (3 modes) |
|
|
||||||
| `app/Http/Controllers/BookmarkletController.php` | GET /bookmarklets/ccli-import.js — serves the bookmarklet JS |
|
|
||||||
| `resources/js/Components/CcliPasteDialog.vue` | Modal: textarea → preview → import buttons (surface-aware) |
|
|
||||||
| `resources/js/Pages/Songs/ImportFromCcliPaste.vue` | Bookmarklet redirect landing page |
|
|
||||||
| `tests/Fixtures/ccli/` | 22 synthetic CCLI-format fixture files for parser tests |
|
|
||||||
|
|
||||||
### Test coverage
|
|
||||||
|
|
||||||
- `tests/Feature/CcliPasteParserTest.php` — parser against all 22 fixtures
|
|
||||||
- `tests/Feature/CcliImportServiceTest.php` — upsert, duplicate, restore, transaction
|
|
||||||
- `tests/Feature/CcliTranslationPairingServiceTest.php` — label pairing, line distribution
|
|
||||||
- `tests/Feature/CcliPasteControllerTest.php` — API endpoints, auth, throttle
|
|
||||||
- `tests/e2e/ccli-paste-import.spec.ts` — SongDB import flow
|
|
||||||
- `tests/e2e/ccli-bookmarklet.spec.ts` — bookmarklet endpoint + Settings page
|
|
||||||
- `tests/e2e/ccli-translation-pairing.spec.ts` — Translate.vue prefill
|
|
||||||
|
|
||||||
### Maintenance note
|
|
||||||
|
|
||||||
If SongSelect changes their HTML structure, update the bookmarklet DOM selectors in `resources/js/bookmarklet/ccli-import.ts` (or the inline JS in `BookmarkletController.php`). The server-side parser (`CcliPasteParser.php`) is independent of SongSelect's HTML — it only parses the plain-text lyrics format.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
## Repository Structure
|
||||||
|
|
||||||
Two git repositories, both local (no remote):
|
Two git repositories, both local (no remote):
|
||||||
|
|
@ -244,63 +105,54 @@ ## Repository Structure
|
||||||
| Repo | Path | Branch | Purpose |
|
| Repo | Path | Branch | Purpose |
|
||||||
|------|------|--------|---------|
|
|------|------|--------|---------|
|
||||||
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
|
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
|
||||||
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
|
| **propresenter-work** | `/Users/thorsten/AI/propresenter-work/php` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
|
||||||
|
|
||||||
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
|
The parser is linked via `composer.json` path repository: `"url": "../propresenter-work/php"`.
|
||||||
|
|
||||||
## Build, Test, Lint Commands
|
## Build, Test, Lint Commands
|
||||||
|
|
||||||
### pp-planer (Laravel App)
|
### pp-planer (Laravel App)
|
||||||
|
|
||||||
Local dev runs in **DDEV** (Docker). Site URL: `https://pp-planer.ddev.site`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Easy onboarding wrappers (no DDEV knowledge required):
|
# First-time setup
|
||||||
./start_dev.sh # ddev start + spawns dev workers in background
|
composer setup # install, .env, key:generate, migrate, npm install, npm build
|
||||||
./stop_dev.sh # stops workers and DDEV (use --keep-ddev to leave DDEV running)
|
|
||||||
|
|
||||||
# Or use DDEV directly:
|
# Dev server (Laravel + Vite + Queue + Logs)
|
||||||
ddev start # composer install, key:generate, migrate, npm install, npm run build
|
composer dev
|
||||||
ddev dev # queue + log tail + vite HMR (foreground)
|
|
||||||
ddev stop # stop the project
|
|
||||||
|
|
||||||
# Open a shell inside the web container
|
# Build frontend
|
||||||
ddev ssh
|
npm run build
|
||||||
|
|
||||||
# Frontend
|
# Run ALL PHP tests (206 tests, clears config cache first)
|
||||||
ddev npm run build
|
composer test
|
||||||
ddev npm run dev
|
php artisan test
|
||||||
|
|
||||||
# Run ALL PHP tests (clears config cache first)
|
|
||||||
ddev composer test
|
|
||||||
ddev exec php artisan test
|
|
||||||
|
|
||||||
# Single test file
|
# Single test file
|
||||||
ddev exec php artisan test tests/Feature/ServiceControllerTest.php
|
php artisan test tests/Feature/ServiceControllerTest.php
|
||||||
|
|
||||||
# Single test method
|
# Single test method
|
||||||
ddev exec php artisan test --filter=test_service_kann_abgeschlossen_werden
|
php artisan test --filter=test_service_kann_abgeschlossen_werden
|
||||||
|
|
||||||
# Test suite
|
# Test suite
|
||||||
ddev exec php artisan test --testsuite=Feature
|
php artisan test --testsuite=Feature
|
||||||
ddev exec php artisan test --testsuite=Unit
|
php artisan test --testsuite=Unit
|
||||||
|
|
||||||
# PHP formatting (Laravel Pint, default preset — no pint.json)
|
# PHP formatting (Laravel Pint, default preset — no pint.json)
|
||||||
ddev exec ./vendor/bin/pint
|
./vendor/bin/pint
|
||||||
ddev exec ./vendor/bin/pint --test # check only
|
./vendor/bin/pint --test # check only
|
||||||
|
|
||||||
# E2E tests (requires `ddev start` running; baseURL is https://pp-planer.ddev.site)
|
# E2E tests (requires dev server at http://pp-planer.test)
|
||||||
npx playwright test
|
npx playwright test
|
||||||
npx playwright test tests/e2e/service-list.spec.ts
|
npx playwright test tests/e2e/service-list.spec.ts
|
||||||
|
|
||||||
# Migrations
|
# Migrations
|
||||||
ddev exec php artisan migrate
|
php artisan migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
### propresenter (Parser Module)
|
### propresenter-work (Parser Module)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/thorsten/AI/propresenter
|
cd /Users/thorsten/AI/propresenter-work/php
|
||||||
|
|
||||||
# Run all tests (230 tests)
|
# Run all tests (230 tests)
|
||||||
./vendor/bin/phpunit
|
./vendor/bin/phpunit
|
||||||
|
|
@ -325,7 +177,7 @@ ## Architecture
|
||||||
tests/Feature/ # Pest v4 / PHPUnit feature tests
|
tests/Feature/ # Pest v4 / PHPUnit feature tests
|
||||||
tests/e2e/ # Playwright browser tests (TypeScript)
|
tests/e2e/ # Playwright browser tests (TypeScript)
|
||||||
|
|
||||||
propresenter/
|
propresenter-work/php/
|
||||||
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
|
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
|
||||||
tests/ # PHPUnit 11 tests with #[Test] attributes
|
tests/ # PHPUnit 11 tests with #[Test] attributes
|
||||||
ref/ # .pro fixture files for testing
|
ref/ # .pro fixture files for testing
|
||||||
|
|
@ -389,7 +241,7 @@ ### ProPresenter Parser Tests (PHPUnit 11)
|
||||||
|
|
||||||
### E2E Tests (Playwright)
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
- TypeScript in `tests/e2e/`, baseURL `https://pp-planer.ddev.site` (HTTPS, `ignoreHTTPSErrors: true` set in `playwright.config.ts`)
|
- TypeScript in `tests/e2e/`, baseURL `http://pp-planer.test`
|
||||||
- Auth via `auth.setup.ts` (XSRF token + `/dev-login` endpoint, saves state to `.auth/user.json`)
|
- Auth via `auth.setup.ts` (XSRF token + `/dev-login` endpoint, saves state to `.auth/user.json`)
|
||||||
- Selectors: `page.getByTestId('...')`, `page.getByText('...')`, `page.getByRole('...')`
|
- Selectors: `page.getByTestId('...')`, `page.getByText('...')`, `page.getByRole('...')`
|
||||||
- German text assertions: `await expect(page.getByText('Mit ChurchTools anmelden')).toBeVisible()`
|
- German text assertions: `await expect(page.getByText('Mit ChurchTools anmelden')).toBeVisible()`
|
||||||
|
|
|
||||||
429
CCLI-API.md
429
CCLI-API.md
|
|
@ -1,429 +0,0 @@
|
||||||
# CCLI SongSelect Partner API — Doc Pointer
|
|
||||||
|
|
||||||
## Where to get the docs
|
|
||||||
|
|
||||||
**Postman documentation** (only public source, no PDF/OpenAPI mirror):
|
|
||||||
https://documenter.getpostman.com/view/604633/TzseGkmA
|
|
||||||
|
|
||||||
The page is JS-rendered. Two ways to read it:
|
|
||||||
1. Open in a browser (Chrome/Firefox), wait for the Postman documenter to render.
|
|
||||||
2. Click the "Run in Postman" button top-right to import the full collection + environment into a Postman workspace — then inspect every endpoint, params, headers, sample requests/responses.
|
|
||||||
|
|
||||||
The collection name is **"SongSelect Partner API"** under owner id `604633`.
|
|
||||||
|
|
||||||
## Status (read first!)
|
|
||||||
|
|
||||||
> **NOTICE: CCLI has retired the SongSelect API Partner Program and is no longer accepting new API partners.**
|
|
||||||
|
|
||||||
Existing partners keep working. New access requires contacting CCLI directly (`partners@ccli.com` / regional CCLI office) to request reinstatement or special arrangement.
|
|
||||||
|
|
||||||
## Key facts (from the docs)
|
|
||||||
|
|
||||||
- **Auth**: OpenID Connect / OAuth 2.0, **Authorization Code with PKCE**, refresh tokens supported
|
|
||||||
- Authorize: `https://identityservices.ccli.com/connect/authorize`
|
|
||||||
- Token: `https://identityservices.ccli.com/connect/token`
|
|
||||||
- Scope: `openid cclipartnerapi.read offline_access`
|
|
||||||
- **Subscription Key**: every request needs header `Ocp-Apim-Subscription-Key: <key>` (dev key for testing, prod key for live)
|
|
||||||
- **Tokens**: access token 1h, refresh token 60-day sliding (one-time use, new refresh returned on each refresh)
|
|
||||||
- **Rate limits**: 100 calls / 10s short term, 300 calls / 5min long term. `429` returns JSON `{statusCode, message}`.
|
|
||||||
- **Dev restrictions**: dev client only sees content for users linked to the "SongSelect API <country> Partners" test organization.
|
|
||||||
- Endpoint reference (search, song detail, lyrics, chord chart, etc.) lives inside the Postman collection — load it to see exact paths/params, not summarized in the public preview.
|
|
||||||
|
|
||||||
## Credentials needed before coding
|
|
||||||
|
|
||||||
1. CCLI Partner ClientId + ClientSecret
|
|
||||||
2. Development Subscription Key (Ocp-Apim-Subscription-Key)
|
|
||||||
3. Production Subscription Key (later)
|
|
||||||
4. A CCLI user account linked to the Partner test organization (for dev refresh-token bootstrap)
|
|
||||||
|
|
||||||
Store in `.env`:
|
|
||||||
```
|
|
||||||
CCLI_PARTNER_CLIENT_ID=
|
|
||||||
CCLI_PARTNER_CLIENT_SECRET=
|
|
||||||
CCLI_PARTNER_SUBSCRIPTION_KEY_DEV=
|
|
||||||
CCLI_PARTNER_SUBSCRIPTION_KEY_PROD=
|
|
||||||
CCLI_PARTNER_REDIRECT_URI=https://pp-planer.ddev.site/oauth/ccli/callback
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bootstrap flow for a new agent
|
|
||||||
|
|
||||||
1. Load Postman collection from URL above → list every endpoint with its path, params, sample response.
|
|
||||||
2. Mirror existing `ChurchToolsService` pattern (`app/Services/ChurchToolsService.php`) — closure-injectable fetcher, `logApiCall`, `classifyError`, German error messages, `ApiRequestLog` row per call.
|
|
||||||
3. Implement OAuth2 PKCE handshake → persist refresh token (encrypted) in a `ccli_tokens` table. Auto-refresh on 401.
|
|
||||||
4. Always send `Ocp-Apim-Subscription-Key` header alongside `Authorization: Bearer <access_token>`.
|
|
||||||
5. Respect rate limits (Laravel `RateLimiter::for('ccli', ...)` with 100/10s + 300/5min buckets).
|
|
||||||
6. Map result to existing schema: `Song.ccli_id`, arrangements + global `Label`s (Strophe 1 / Refrain / Bridge), `SongSlide.text_content`. See `ProImportService::upsertSong` for the upsert template.
|
|
||||||
|
|
||||||
## Fallback if API access denied
|
|
||||||
|
|
||||||
- Manual paste flow → parser splits on `Verse N`, `Chorus`, `Bridge`, `Pre-Chorus`, `Tag`, `Ending` headings.
|
|
||||||
- `.pro` import already implemented (`POST /api/songs/import-pro`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Alternative: Headless-browser scraping (NO official API)
|
|
||||||
|
|
||||||
Use this when the Partner API is not available (current default for new projects). It drives `songselect.ccli.com` with a real browser session using a normal CCLI SongSelect subscription. Same data the user would download manually, just automated.
|
|
||||||
|
|
||||||
## ToS / legal note
|
|
||||||
|
|
||||||
CCLI's SongSelect ToS forbids "automated retrieval" without partner agreement. A church-internal tool that only acts on behalf of an authenticated subscriber and respects rate limits is a gray area many open-source projects (OpenLP, FreeShow community fork, `gwonamfromkoradai/SongSelectSave`) operate in. Document the risk in `README` and let the church decide.
|
|
||||||
|
|
||||||
## Required credentials
|
|
||||||
|
|
||||||
```
|
|
||||||
CCLI_SONGSELECT_USER= # CCLI account email
|
|
||||||
CCLI_SONGSELECT_PASSWORD= # CCLI account password
|
|
||||||
CCLI_SONGSELECT_BASE_URL=https://songselect.ccli.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Single shared app account (chosen). Encrypt the password at rest (`Crypt::encryptString`) — never log it.
|
|
||||||
|
|
||||||
## Tech stack pick
|
|
||||||
|
|
||||||
Three viable headless-browser options for Laravel:
|
|
||||||
|
|
||||||
| Tool | Pros | Cons |
|
|
||||||
|---|---|---|
|
|
||||||
| **`spatie/browsershot`** (Puppeteer + Chromium via Node) | Already in Laravel ecosystem; simple PHP API; supports cookies, headers, screenshots | Heavyweight; needs Node + Chromium in container |
|
|
||||||
| **`laravel/dusk`** (ChromeDriver) | Pure Laravel; auth helpers; assertion DSL | Built for testing, awkward for prod scraping |
|
|
||||||
| **Playwright via Node side-script** (`tests/e2e` already uses it) | Best automation API; persistent storage state; identical to existing E2E setup | Crosses PHP↔Node boundary (CLI exec or queue worker) |
|
|
||||||
|
|
||||||
**Recommendation: Playwright** — already a dev dep, `tests/e2e/auth.setup.ts` proves the pattern. Run as a queue job that shells out to a Node script, returns JSON.
|
|
||||||
|
|
||||||
DDEV needs Chromium installed — add to `.ddev/web-build/Dockerfile.example`:
|
|
||||||
```dockerfile
|
|
||||||
RUN apt-get update && apt-get install -y chromium fonts-liberation
|
|
||||||
RUN npx --yes playwright install --with-deps chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
## Endpoints / DOM contract (observed)
|
|
||||||
|
|
||||||
These are not an "API" — they are URL + selector contracts that can change. Re-verify quarterly.
|
|
||||||
|
|
||||||
### 1. Login
|
|
||||||
- URL: `https://profile.ccli.com/account/signin?appContext=SongSelect`
|
|
||||||
- Form fields: `input[name="EmailAddress"]`, `input[name="Password"]`, `button[type="submit"]`
|
|
||||||
- Success: redirect to `https://songselect.ccli.com/`
|
|
||||||
- Persist cookies (`profile.ccli.com`, `songselect.ccli.com`) in `storage/app/ccli/state.json` (Playwright `storageState`). Re-login when cookies expire.
|
|
||||||
|
|
||||||
### 2. Search by keyword
|
|
||||||
- URL: `https://songselect.ccli.com/search/results?Keyword={url-encoded-query}`
|
|
||||||
- Result rows: `.song-result` (or current class — verify with DevTools)
|
|
||||||
- Fields per row: `.song-title a` (link + title), `.song-authors` (authors), `.song-ccli-number` or attribute `data-id` (CCLI #)
|
|
||||||
- Pagination: `?Keyword=...&CurrentPage=2`
|
|
||||||
|
|
||||||
### 3. Search by CCLI number
|
|
||||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}` → redirects to canonical song page
|
|
||||||
|
|
||||||
### 4. Song detail
|
|
||||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}`
|
|
||||||
- Metadata in `<dl>` or schema.org JSON-LD `<script type="application/ld+json">` (preferred — stable):
|
|
||||||
- `name` → title
|
|
||||||
- `author[].name` → authors
|
|
||||||
- `copyrightYear`, `copyrightHolder`
|
|
||||||
- Themes / publishers in side panel.
|
|
||||||
|
|
||||||
### 5. Lyrics download (the "parts" the user wants)
|
|
||||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}/viewlyrics`
|
|
||||||
- Trigger: click `#lyricsDownloadButton` (gives `.txt`) OR fetch hidden link `a[data-download-format="txt"]`
|
|
||||||
- The `.txt` payload is **structured by part**, e.g.:
|
|
||||||
```
|
|
||||||
Verse 1
|
|
||||||
Amazing grace, how sweet the sound
|
|
||||||
...
|
|
||||||
|
|
||||||
Chorus
|
|
||||||
My chains are gone...
|
|
||||||
|
|
||||||
Verse 2
|
|
||||||
...
|
|
||||||
|
|
||||||
Bridge
|
|
||||||
...
|
|
||||||
|
|
||||||
CCLI Song # 22025
|
|
||||||
© Public Domain
|
|
||||||
CCLI License # 12345
|
|
||||||
```
|
|
||||||
- Headers to detect (regex): `^(Verse \d+|Chorus( \d+)?|Pre-Chorus|Bridge( \d+)?|Tag|Ending|Intro|Interlude|Refrain|Coda)\s*$`
|
|
||||||
|
|
||||||
### 6. ChordPro download (optional, if account has chord access)
|
|
||||||
- URL: `https://songselect.ccli.com/Songs/{ccliId}/{slug}/chordpro` → click `.chordpro-download`
|
|
||||||
- Format is industry-standard ChordPro — easier to parse than HTML.
|
|
||||||
|
|
||||||
## Mapping to existing schema
|
|
||||||
|
|
||||||
```
|
|
||||||
SongSelect part header → global Label name
|
|
||||||
─────────────────────────────────────────────
|
|
||||||
Verse N → Strophe N
|
|
||||||
Chorus / Refrain → Refrain
|
|
||||||
Pre-Chorus → Pre-Refrain
|
|
||||||
Bridge → Bridge
|
|
||||||
Tag / Ending / Coda → Outro
|
|
||||||
Intro / Interlude → Intro / Zwischenspiel
|
|
||||||
```
|
|
||||||
|
|
||||||
Lookup labels case-insensitive (`SongService::createDefaultGroups` already does `LOWER(name)`); create new global label if no match.
|
|
||||||
|
|
||||||
Persistence template (mirror `ProImportService::upsertSong`):
|
|
||||||
1. `Song::firstOrNew(['ccli_id' => $ccliId])` — restore soft-deleted via `restore()`
|
|
||||||
2. Update title / author / copyright_text / copyright_year / publisher
|
|
||||||
3. Wipe existing arrangements for clean re-import (or skip if user opted "merge")
|
|
||||||
4. Create one `SongArrangement(name='Normal', is_default=true)`
|
|
||||||
5. For each parsed part → find/create `Label`, create `SongSlide(label_id, order, text_content)`, attach via `SongArrangementLabel(order)`
|
|
||||||
|
|
||||||
## Service skeleton
|
|
||||||
|
|
||||||
```php
|
|
||||||
// app/Services/SongSelectScraperService.php
|
|
||||||
final class SongSelectScraperService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly SongImportService $importer,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function search(string $query): Collection { /* runs node script: search */ }
|
|
||||||
|
|
||||||
public function fetchByCcliId(int $ccliId): array { /* runs node script: detail+lyrics */ }
|
|
||||||
|
|
||||||
public function importToDb(int $ccliId): Song
|
|
||||||
{
|
|
||||||
$payload = $this->fetchByCcliId($ccliId);
|
|
||||||
return $this->importer->upsertFromSongSelect($payload); // mirrors ProImportService
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run scraper inside a queue job (`ScrapeSongSelectJob`) — never block HTTP request. Frontend polls or uses Inertia partial reload.
|
|
||||||
|
|
||||||
## Node side-script (Playwright)
|
|
||||||
|
|
||||||
`scripts/songselect-fetch.mjs`:
|
|
||||||
```js
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
const [, , action, arg] = process.argv; // e.g. 'search' 'amazing grace' OR 'detail' 22025
|
|
||||||
const STATE = 'storage/app/ccli/state.json';
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const ctx = fs.existsSync(STATE)
|
|
||||||
? await browser.newContext({ storageState: STATE })
|
|
||||||
: await browser.newContext();
|
|
||||||
const page = await ctx.newPage();
|
|
||||||
|
|
||||||
// auto-login if cookies missing
|
|
||||||
await page.goto('https://songselect.ccli.com/');
|
|
||||||
if (await page.locator('text=Sign In').isVisible().catch(() => false)) {
|
|
||||||
await page.goto('https://profile.ccli.com/account/signin?appContext=SongSelect');
|
|
||||||
await page.fill('input[name="EmailAddress"]', process.env.CCLI_SONGSELECT_USER);
|
|
||||||
await page.fill('input[name="Password"]', process.env.CCLI_SONGSELECT_PASSWORD);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await page.waitForURL('**/songselect.ccli.com/**');
|
|
||||||
await ctx.storageState({ path: STATE });
|
|
||||||
}
|
|
||||||
|
|
||||||
let result;
|
|
||||||
if (action === 'search') {
|
|
||||||
await page.goto(`https://songselect.ccli.com/search/results?Keyword=${encodeURIComponent(arg)}`);
|
|
||||||
result = await page.$$eval('.song-result', rows => rows.map(r => ({
|
|
||||||
ccli_id: r.dataset.id ?? r.querySelector('.song-ccli-number')?.textContent?.trim(),
|
|
||||||
title: r.querySelector('.song-title')?.textContent?.trim(),
|
|
||||||
authors: r.querySelector('.song-authors')?.textContent?.trim(),
|
|
||||||
url: r.querySelector('a')?.href,
|
|
||||||
})));
|
|
||||||
} else if (action === 'detail') {
|
|
||||||
await page.goto(`https://songselect.ccli.com/Songs/${arg}`);
|
|
||||||
const url = page.url();
|
|
||||||
const meta = await page.$eval('script[type="application/ld+json"]', s => JSON.parse(s.textContent));
|
|
||||||
await page.goto(url.replace(/\/?$/, '/viewlyrics'));
|
|
||||||
const lyrics = await page.locator('pre, .lyrics-content').innerText();
|
|
||||||
result = { ccli_id: arg, ...meta, lyrics };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
await browser.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
PHP side calls via `Symfony\Component\Process\Process` and decodes JSON.
|
|
||||||
|
|
||||||
## Lyrics → parts parser (PHP)
|
|
||||||
|
|
||||||
```php
|
|
||||||
final class SongSelectLyricsParser
|
|
||||||
{
|
|
||||||
private const HEADER = '/^(Verse \d+|Chorus(?: \d+)?|Pre-Chorus|Bridge(?: \d+)?|Tag|Ending|Intro|Interlude|Refrain|Coda)\s*$/i';
|
|
||||||
private const LABEL_MAP = [
|
|
||||||
'verse' => 'Strophe', // suffix the number
|
|
||||||
'chorus' => 'Refrain',
|
|
||||||
'refrain' => 'Refrain',
|
|
||||||
'pre-chorus' => 'Pre-Refrain',
|
|
||||||
'bridge' => 'Bridge',
|
|
||||||
'tag' => 'Outro',
|
|
||||||
'ending' => 'Outro',
|
|
||||||
'coda' => 'Outro',
|
|
||||||
'intro' => 'Intro',
|
|
||||||
'interlude' => 'Zwischenspiel',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @return array<int, array{label: string, text: string}> */
|
|
||||||
public function parse(string $raw): array { /* split on HEADER, map via LABEL_MAP */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate limiting & politeness
|
|
||||||
|
|
||||||
- Cap to **30 requests/minute** per app instance (`RateLimiter::for('ccli-scrape', fn () => Limit::perMinute(30))`).
|
|
||||||
- One concurrent scrape job (`ScrapeSongSelectJob` with `WithoutOverlapping` middleware).
|
|
||||||
- Cache result for 30 days (`songs.ccli_id` already keyed). User can force-refresh via "Re-import" button.
|
|
||||||
- Random jitter 500-1500ms between page loads.
|
|
||||||
|
|
||||||
## UI integration
|
|
||||||
|
|
||||||
1. **`Songs/Index.vue`** — top-bar search input "CCLI Lookup" → `POST /api/ccli/search { q }` → modal with results → "Import" button per row.
|
|
||||||
2. **`SongAgendaItem.vue`** (unmatched row) — new button "SongSelect suchen" next to existing Request/Assign → opens same modal pre-filled with CTS song name.
|
|
||||||
3. **Preview modal before save** — show parsed parts grouped by detected Label, allow drag-reassign / rename, then confirm import.
|
|
||||||
4. All German text, Du-form: "Suche bei CCLI…", "Importieren", "Als Strophe 1 zuweisen", etc.
|
|
||||||
|
|
||||||
## Failure modes & detection
|
|
||||||
|
|
||||||
| Symptom | Cause | Action |
|
|
||||||
|---|---|---|
|
|
||||||
| Redirect to `/account/signin` mid-session | Cookie expired | Re-run login flow, retry once |
|
|
||||||
| Empty `.song-result` list | DOM changed OR query 0 hits | Save HTML snapshot to `storage/logs/ccli/` for inspection |
|
|
||||||
| HTTP 429 / "Too many requests" page | Rate limit hit | Back off 5min, alert admin |
|
|
||||||
| Captcha (`recaptcha` iframe) | CCLI flagged automation | Stop, surface admin notice, fall back to manual paste |
|
|
||||||
| Login fails | Wrong creds OR account suspended | German error to admin |
|
|
||||||
|
|
||||||
Log every scrape into `api_request_logs` (existing table) with `service='songselect'` so the existing log UI shows them alongside CTS calls.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Unit-test the parser with fixtures in `tests/Fixtures/songselect/*.txt`.
|
|
||||||
- Mock the Playwright invocation in service tests via constructor closure (mirror `ChurchToolsService` pattern).
|
|
||||||
- E2E test against a sandbox public-domain song (e.g. CCLI #22025 "Amazing Grace") — gated by `CCLI_SONGSELECT_USER` env, skip if missing.
|
|
||||||
|
|
||||||
## Bootstrap checklist for a new agent
|
|
||||||
|
|
||||||
1. Confirm CCLI subscription credentials are in `.env`.
|
|
||||||
2. Add Chromium to DDEV web container.
|
|
||||||
3. Create `scripts/songselect-fetch.mjs`.
|
|
||||||
4. Create `app/Services/SongSelectScraperService.php` + `SongSelectLyricsParser.php` + `SongImportService::upsertFromSongSelect()` (refactor common parts out of `ProImportService`).
|
|
||||||
5. Create `ScrapeSongSelectJob` (queued, `WithoutOverlapping`).
|
|
||||||
6. Add routes `POST /api/ccli/search`, `POST /api/ccli/import/{ccliId}`.
|
|
||||||
7. Add Vue search modal + integrate into `Songs/Index.vue` + `SongAgendaItem.vue`.
|
|
||||||
8. Write parser unit tests + service feature test (mock Process).
|
|
||||||
9. Document the ToS gray area in README.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Reference: How OpenLP imports from CCLI
|
|
||||||
|
|
||||||
Source: `openlp/plugins/songs/lib/songselect.py` on https://gitlab.com/openlp/openlp (LGPL).
|
|
||||||
|
|
||||||
**Approach: embedded Qt WebEngine (= real Chromium) + JS injection**
|
|
||||||
|
|
||||||
OpenLP does NOT do headless HTTP scraping. It opens a `QWebEngineView` (PySide6 Qt Chromium) inside the desktop app on `https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https%3a%2f%2fsongselect.ccli.com%2f`. The user signs in **manually** in that embedded browser (so they solve any captcha themselves). After login the same webview holds the authenticated cookies.
|
|
||||||
|
|
||||||
OpenLP then drives the page via `webview.page().runJavaScript(...)` to:
|
|
||||||
|
|
||||||
1. Detect current page by URL (`Login` / `Home` / `Search` / `Song` / `Other`).
|
|
||||||
2. Navigate by setting `document.location = "<url>"`.
|
|
||||||
3. Pre-fill login fields:
|
|
||||||
```js
|
|
||||||
document.getElementById("EmailAddress").value = "<email>";
|
|
||||||
document.getElementById("Password").value = "<password>";
|
|
||||||
```
|
|
||||||
(User still clicks Sign-In manually so Turnstile sees a real interaction.)
|
|
||||||
4. **Fetch any URL with the page's session cookies** by injecting:
|
|
||||||
```js
|
|
||||||
var openlp_page_data = null;
|
|
||||||
fetch("<url>")
|
|
||||||
.then(r => r.text())
|
|
||||||
.then(t => { openlp_page_data = t; });
|
|
||||||
```
|
|
||||||
then polls `openlp_page_data != null` and reads the result back into Python. This is the clever bit — they bypass cookie-export entirely, using the already-authenticated browser context as the HTTP client.
|
|
||||||
5. Parse HTML → song dict → write into the OpenLP DB via SQLAlchemy (`Song`, `Author`, `Topic`, `SongXML` verses with `VerseType.tags`).
|
|
||||||
|
|
||||||
URL constants in OpenLP:
|
|
||||||
```python
|
|
||||||
BASE_URL = 'https://songselect.ccli.com'
|
|
||||||
LOGIN_PAGE = 'https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https%3a%2f%2fsongselect.ccli.com%2f'
|
|
||||||
LOGIN_URL = 'https://profile.ccli.com'
|
|
||||||
LOGOUT_URL = BASE_URL + '/account/logout'
|
|
||||||
SEARCH_URL = BASE_URL + '/search/results'
|
|
||||||
SONG_PAGE = BASE_URL + '/Songs/'
|
|
||||||
CCLI_NUMBER_REGEX = r'.*?Songs\/([0-9]+).*'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lesson for a Laravel server-side port**: OpenLP succeeds because it ships a full GUI Chromium and pushes the captcha problem onto the user. A server-side scraper has to solve the same captcha non-interactively — see next section.
|
|
||||||
|
|
||||||
# Cloudflare Turnstile on CCLI login (verified 2026-05)
|
|
||||||
|
|
||||||
Confirmed by fetching `https://profile.ccli.com/account/signin?appContext=SongSelect`:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
|
|
||||||
<div class="cf-turnstile sr-only"
|
|
||||||
data-sitekey="0x4AAAAAAA1USwfe0YamenZA"
|
|
||||||
data-appearance="interaction-only"
|
|
||||||
data-callback="enableSubmit" inert></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Mode**: `interaction-only` (Managed/Invisible — silent unless trust score drops, then escalates to checkbox click)
|
|
||||||
- **Sitekey**: `0x4AAAAAAA1USwfe0YamenZA`
|
|
||||||
- **Submit button is disabled until Turnstile callback fires**, then a hidden `cf-turnstile-response` input is added to the POST body
|
|
||||||
- Form also includes ASP.NET `__RequestVerificationToken` (CSRF) — must be scraped from the GET response and sent back
|
|
||||||
- CCLI also injects **Cloudflare Bot Management JSD** (`/cdn-cgi/challenge-platform/scripts/jsd/main.js`) — additional passive fingerprinting on every page
|
|
||||||
|
|
||||||
## Can Turnstile be bypassed WITHOUT a real Chrome?
|
|
||||||
|
|
||||||
**Short answer: No.** Turnstile requires a JavaScript runtime + canvas + WebGL + AudioContext + matching TLS/JA3 fingerprint to mint a valid token. A real browser engine must run somewhere — locally, in a queue worker, or in the cloud.
|
|
||||||
|
|
||||||
The realistic option matrix:
|
|
||||||
|
|
||||||
| Approach | "Real Chrome" needed? | Cost | Reliability for CCLI | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **Pure HTTP** (Guzzle / curl / requests) | none | free | **Will not work** | Cannot execute the Turnstile JS that mints the token. Hard wall. |
|
|
||||||
| **`curl-impersonate` / `curl_cffi`** (TLS-fingerprint spoofing) | none | free | **Will not work alone** | Solves JA3 fingerprint but still no JS engine for the Turnstile widget. Useful only AFTER a session cookie exists. |
|
|
||||||
| **Patched headless Chromium** (Playwright + `playwright-stealth`, `puppeteer-extra-plugin-stealth`, `nodriver`, `patchright`) | yes (local) | free | **Medium** for `interaction-only` mode | Stealth plugins hide `navigator.webdriver`, fix canvas/WebGL leaks. Often passes Turnstile silently. Breaks under residential-IP requirement or escalation to interactive. |
|
|
||||||
| **`undetected-chromedriver` + SeleniumBase UC Mode** | yes (local) | free | **Medium-High** | Has built-in `uc_gui_click_captcha()` that uses pyautogui to click the checkbox if Turnstile escalates. Python-only. |
|
|
||||||
| **Camoufox** (patched Firefox, fingerprint injection at C++ level) | yes (local) | free | **Medium-High** | Different signature from Chromium-based detection profiles; useful when stealth-Chromium gets flagged. |
|
|
||||||
| **CAPTCHA-solving service** (2Captcha, CapSolver, NextCaptcha, Anti-Captcha) | none locally; service runs browsers | ≈$1.45/1k tokens | **Low for CCLI specifically** | They return a Turnstile token bound to the sitekey + your IP. CCLI also fingerprints the browser env + JSD beacon, so token alone often fails to authenticate. Token TTL ≈ 5min, single-use. |
|
|
||||||
| **Cloud browser API** (Scrapfly ASP, Browserless, Bright Data Scraping Browser, Scrapeless, ZenRows, Oxylabs Web Unblocker) | yes (remote) | ≈$5-50/1k pages | **High** | Real Chromium + residential proxy + automatic challenge solving in one call. The only "no local Chrome" option that actually works at scale. |
|
|
||||||
| **Manual one-time login + persisted cookies** (OpenLP model) | yes (one-time, in user's own browser) | free | **High** | User logs in once via popup/embedded view, app stores `.AspNet.ApplicationCookie` + Cloudflare `cf_clearance` cookies, reuses them for HTTP scraping until they expire (typically 30 days; `cf_clearance` is shorter ≈ 1 hour but auto-refreshes if you keep the same browser fingerprint via `curl-impersonate`). |
|
|
||||||
|
|
||||||
**`cf_clearance` cookie pitfall**: even with a valid `.AspNet.ApplicationCookie`, Cloudflare checks `cf_clearance` on every request and ties it to the originating browser's TLS+UA fingerprint. Reusing the cookie from raw `curl` will give `403 / cf_chl_*` because the JA3 fingerprint won't match. Use `curl-impersonate-chrome` or `curl_cffi` (`curl_cffi.requests` with `impersonate="chrome120"`) so the TLS handshake matches the browser that minted the cookie.
|
|
||||||
|
|
||||||
## Recommended architecture for pp-planer
|
|
||||||
|
|
||||||
Hybrid that mirrors OpenLP's user-driven login but server-side scraping:
|
|
||||||
|
|
||||||
1. **Admin panel "CCLI Session" page**
|
|
||||||
- "Sign in to CCLI" button opens a popup window pointed at `https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=https://pp-planer.ddev.site/api/ccli/oauth-callback`.
|
|
||||||
- User logs in normally. Their own browser handles Turnstile (silent in 99% of cases for residential IPs).
|
|
||||||
- On the redirect back to our callback, JS reads `document.cookie` from the popup (only works for cookies on **our** domain — see below) — so this approach actually requires a different mechanism.
|
|
||||||
|
|
||||||
2. **Better: bundled headless browser inside a queue worker**
|
|
||||||
- Use Playwright (already a dev dep) + `playwright-extra` + `playwright-extra-plugin-stealth` in headed mode for first login, headless for re-use.
|
|
||||||
- Persist `storageState` to `storage/app/ccli/state.json` (encrypted at rest).
|
|
||||||
- First-time setup: admin runs `php artisan ccli:login` → opens a non-headless Playwright browser on the server's display (or via VNC/X11 forwarding in DDEV) → admin types credentials and solves any escalated Turnstile checkbox.
|
|
||||||
- All subsequent fetches use saved cookies in headless mode. Re-prompt admin when cookies expire.
|
|
||||||
|
|
||||||
3. **For ongoing fetches**: once authenticated, can drop down to `curl_cffi`-style HTTP via Symfony HttpClient with a Chrome JA3 fingerprint (PHP package: `quic-go/curl-impersonate` shell-out, or call Node `curl-impersonate` script) — much faster than re-launching browser per request.
|
|
||||||
|
|
||||||
4. **Fallback if Turnstile escalates beyond stealth limits**: route through a cloud browser (Scrapfly ASP `asp=true` flag handles it). Make it pluggable behind `SongSelectClient` interface.
|
|
||||||
|
|
||||||
## Honest recommendation
|
|
||||||
|
|
||||||
For a church-internal tool used by a handful of staff, scraping at all is overkill. Realistic ranking:
|
|
||||||
|
|
||||||
1. **Manual paste flow** + lyric parser → 2 days of work, zero external deps, zero ToS risk.
|
|
||||||
2. **`.pro` import** (already done) — staff can download `.pro` files from SongSelect manually and drop them in the existing upload area.
|
|
||||||
3. **OpenLP-style embedded webview** — only works for desktop; doesn't fit a Laravel web app.
|
|
||||||
4. **Server-side stealth Playwright + persisted cookies** — works, but ~1-2 weeks of fragile glue code, breaks every CCLI redesign or Cloudflare ruleset bump.
|
|
||||||
5. **Cloud browser API (Scrapfly etc.)** — most reliable, costs €€, still ToS-gray.
|
|
||||||
|
|
||||||
If automation is mandatory: option 4 with option 5 as fallback when the local browser fails.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class DuplicateCcliSongException extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $existingSongId,
|
|
||||||
string $message = '',
|
|
||||||
) {
|
|
||||||
parent::__construct($message ?: "Song mit dieser CCLI-Nummer existiert bereits (#{$existingSongId})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Label;
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\SongArrangement;
|
use App\Models\SongArrangement;
|
||||||
use App\Models\SongSection;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -25,24 +23,17 @@ public function store(Request $request, Song $song): RedirectResponse
|
||||||
'is_default' => false,
|
'is_default' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$defaultArr = $song->arrangements()->where('is_default', true)->first();
|
$groups = $song->groups()->orderBy('order')->get();
|
||||||
|
$rows = $groups->map(fn ($group, $index) => [
|
||||||
if ($defaultArr === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$arrangementSections = $defaultArr->arrangementSections()->orderBy('order')->get();
|
|
||||||
|
|
||||||
$rows = $arrangementSections->values()->map(fn ($arrangementSection, $index) => [
|
|
||||||
'song_arrangement_id' => $arrangement->id,
|
'song_arrangement_id' => $arrangement->id,
|
||||||
'song_section_id' => $arrangementSection->song_section_id,
|
'song_group_id' => $group->id,
|
||||||
'order' => $index + 1,
|
'order' => $index + 1,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
])->all();
|
])->all();
|
||||||
|
|
||||||
if ($rows !== []) {
|
if ($rows !== []) {
|
||||||
$arrangement->arrangementSections()->insert($rows);
|
$arrangement->arrangementGroups()->insert($rows);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -56,14 +47,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::transaction(function () use ($arrangement, $data): void {
|
DB::transaction(function () use ($arrangement, $data): void {
|
||||||
$arrangement->loadMissing('arrangementSections');
|
$arrangement->loadMissing('arrangementGroups');
|
||||||
|
|
||||||
$clone = $arrangement->song->arrangements()->create([
|
$clone = $arrangement->song->arrangements()->create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'is_default' => false,
|
'is_default' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->cloneArrangementLabels($arrangement, $clone);
|
$this->cloneGroups($arrangement, $clone);
|
||||||
});
|
});
|
||||||
|
|
||||||
return back()->with('success', 'Arrangement wurde geklont.');
|
return back()->with('success', 'Arrangement wurde geklont.');
|
||||||
|
|
@ -73,23 +64,33 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'groups' => ['array'],
|
'groups' => ['array'],
|
||||||
'groups.*.section_id' => ['nullable', 'integer', 'exists:song_sections,id'],
|
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
|
||||||
'groups.*.label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
|
||||||
'groups.*.order' => ['required', 'integer', 'min:1'],
|
'groups.*.order' => ['required', 'integer', 'min:1'],
|
||||||
'group_colors' => ['sometimes', 'array'],
|
'group_colors' => ['sometimes', 'array'],
|
||||||
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sectionIds = $this->sectionIdsForGroups($arrangement, $data['groups'] ?? []);
|
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
|
||||||
|
$uniqueGroupIds = $groupIds->unique()->values();
|
||||||
|
|
||||||
DB::transaction(function () use ($arrangement, $sectionIds, $data): void {
|
$validGroupIds = $arrangement->song->groups()
|
||||||
$arrangement->arrangementSections()->delete();
|
->whereIn('id', $uniqueGroupIds)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
$rows = $sectionIds
|
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
|
||||||
->values()
|
->values()
|
||||||
->map(fn (int $sectionId, int $index) => [
|
->map(fn (int $songGroupId, int $index) => [
|
||||||
'song_arrangement_id' => $arrangement->id,
|
'song_arrangement_id' => $arrangement->id,
|
||||||
'song_section_id' => $sectionId,
|
'song_group_id' => $songGroupId,
|
||||||
'order' => $index + 1,
|
'order' => $index + 1,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
|
|
@ -97,19 +98,14 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($rows !== []) {
|
if ($rows !== []) {
|
||||||
$arrangement->arrangementSections()->insert($rows);
|
$arrangement->arrangementGroups()->insert($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($data['group_colors'])) {
|
if (! empty($data['group_colors'])) {
|
||||||
$sections = SongSection::whereIn('id', collect(array_keys($data['group_colors']))->map(fn ($id) => (int) $id))
|
foreach ($data['group_colors'] as $groupId => $color) {
|
||||||
->get()
|
$arrangement->song->groups()
|
||||||
->keyBy('id');
|
->whereKey((int) $groupId)
|
||||||
|
->update(['color' => $color]);
|
||||||
foreach ($data['group_colors'] as $id => $color) {
|
|
||||||
$section = $sections->get((int) $id);
|
|
||||||
$labelId = $section?->label_id ?? (int) $id;
|
|
||||||
|
|
||||||
Label::whereKey($labelId)->update(['color' => $color]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -140,62 +136,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
|
||||||
return back()->with('success', 'Arrangement wurde gelöscht.');
|
return back()->with('success', 'Arrangement wurde gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void
|
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
|
||||||
{
|
{
|
||||||
if ($source === null) {
|
if ($source === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$arrangementSections = $source->arrangementSections
|
$groups = $source->arrangementGroups
|
||||||
->sortBy('order')
|
->sortBy('order')
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$rows = $arrangementSections
|
$rows = $groups
|
||||||
->map(fn ($arrangementSection) => [
|
->map(fn ($arrangementGroup) => [
|
||||||
'song_arrangement_id' => $target->id,
|
'song_arrangement_id' => $target->id,
|
||||||
'song_section_id' => $arrangementSection->song_section_id,
|
'song_group_id' => $arrangementGroup->song_group_id,
|
||||||
'order' => $arrangementSection->order,
|
'order' => $arrangementGroup->order,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($rows !== []) {
|
if ($rows !== []) {
|
||||||
$target->arrangementSections()->insert($rows);
|
$target->arrangementGroups()->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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<?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',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Exceptions\DuplicateCcliSongException;
|
|
||||||
use App\Models\ServiceSong;
|
|
||||||
use App\Models\Song;
|
|
||||||
use App\Services\CcliImportService;
|
|
||||||
use App\Services\CcliPasteParser;
|
|
||||||
use App\Services\CcliTranslationPairingService;
|
|
||||||
use App\Services\SongMatchingService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response as InertiaResponse;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class CcliPasteController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CcliPasteParser $parser,
|
|
||||||
private readonly CcliImportService $importService,
|
|
||||||
private readonly CcliTranslationPairingService $pairingService,
|
|
||||||
private readonly SongMatchingService $matchingService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ccli/preview
|
|
||||||
* Parse raw text and return DTO as JSON. No DB writes.
|
|
||||||
*/
|
|
||||||
public function preview(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'raw_text' => ['required', 'string', 'max:200000'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$parsed = $this->parser->parse($validated['raw_text']);
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'title' => $parsed->title,
|
|
||||||
'author' => $parsed->author,
|
|
||||||
'ccliId' => $parsed->ccliId,
|
|
||||||
'year' => $parsed->year,
|
|
||||||
'copyrightText' => $parsed->copyrightText,
|
|
||||||
'sections' => array_map(fn ($s) => [
|
|
||||||
'label' => $s->label,
|
|
||||||
'kind' => $s->kind,
|
|
||||||
'number' => $s->number,
|
|
||||||
'modifier' => $s->modifier,
|
|
||||||
'lines' => $s->lines,
|
|
||||||
'hasTranslation' => $s->linesTranslated !== null,
|
|
||||||
], $parsed->sections),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/songs/import-from-ccli-paste
|
|
||||||
* Import a CCLI paste in one of 3 modes.
|
|
||||||
*/
|
|
||||||
public function importPaste(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'raw_text' => ['required', 'string', 'max:200000'],
|
|
||||||
'mode' => ['required', Rule::in(['create', 'pair-with-song', 'assign-to-service-song'])],
|
|
||||||
'target_id' => ['required_unless:mode,create', 'nullable', 'integer'],
|
|
||||||
'source_url' => ['nullable', 'string', 'max:500'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rawText = $validated['raw_text'];
|
|
||||||
$mode = $validated['mode'];
|
|
||||||
$targetId = $validated['target_id'] ?? null;
|
|
||||||
$sourceUrl = $validated['source_url'] ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($mode === 'create') {
|
|
||||||
$result = $this->importService->import($rawText, $sourceUrl);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'song_id' => $result['song']->id,
|
|
||||||
'status' => $result['status'],
|
|
||||||
'warnings' => $result['warnings'],
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($mode === 'pair-with-song') {
|
|
||||||
$localSong = Song::findOrFail($targetId);
|
|
||||||
$result = $this->pairingService->pair($localSong, $rawText);
|
|
||||||
|
|
||||||
session()->flash('ccli_prefilled', $result['distributed_text']);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'song_id' => $localSong->id,
|
|
||||||
'mapping' => $result['mapping'],
|
|
||||||
'unmatched_labels' => $result['unmatched_labels'],
|
|
||||||
'redirect_to' => route('songs.translate', $localSong->id).'?prefilled=true',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($mode === 'assign-to-service-song') {
|
|
||||||
$result = $this->importService->import($rawText, $sourceUrl);
|
|
||||||
$song = $result['song'];
|
|
||||||
|
|
||||||
$serviceSong = ServiceSong::findOrFail($targetId);
|
|
||||||
$this->matchingService->manualAssign($serviceSong, $song);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'song_id' => $song->id,
|
|
||||||
'service_song_id' => $serviceSong->id,
|
|
||||||
'status' => $result['status'],
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
} catch (DuplicateCcliSongException $e) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
'existing_song_id' => $e->existingSongId,
|
|
||||||
'edit_url' => route('songs.index').'#song-'.$e->existingSongId,
|
|
||||||
], 409);
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['message' => 'Unbekannter Modus'], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /songs/import-from-ccli-paste
|
|
||||||
* Render the Inertia page for bookmarklet redirect.
|
|
||||||
*/
|
|
||||||
public function showImportPage(Request $request): InertiaResponse
|
|
||||||
{
|
|
||||||
$prefill = $request->query('prefill');
|
|
||||||
$prefilledText = null;
|
|
||||||
$prefilledMetadata = null;
|
|
||||||
$prefillError = null;
|
|
||||||
|
|
||||||
if ($prefill !== null && is_string($prefill)) {
|
|
||||||
try {
|
|
||||||
$decoded = base64_decode($prefill, strict: true);
|
|
||||||
if ($decoded === false) {
|
|
||||||
throw new InvalidArgumentException('Ungültige Kodierung');
|
|
||||||
}
|
|
||||||
$payload = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
if (! is_array($payload)) {
|
|
||||||
throw new InvalidArgumentException('Ungültiges Payload-Format');
|
|
||||||
}
|
|
||||||
|
|
||||||
$prefilledText = $payload['rawText'] ?? null;
|
|
||||||
$prefilledMetadata = [
|
|
||||||
'title' => $payload['title'] ?? null,
|
|
||||||
'author' => $payload['author'] ?? null,
|
|
||||||
'ccliId' => $payload['ccliId'] ?? null,
|
|
||||||
'sourceUrl' => $payload['sourceUrl'] ?? null,
|
|
||||||
];
|
|
||||||
} catch (Throwable) {
|
|
||||||
$prefillError = 'Lesezeichen-Daten konnten nicht gelesen werden. Bitte den Liedtext manuell einfügen.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::render('Songs/ImportFromCcliPaste', [
|
|
||||||
'prefilledText' => $prefilledText,
|
|
||||||
'prefilledMetadata' => $prefilledMetadata,
|
|
||||||
'prefillError' => $prefillError,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,23 +49,15 @@ public function importPro(Request $request): JsonResponse
|
||||||
|
|
||||||
public function downloadPro(Song $song): BinaryFileResponse
|
public function downloadPro(Song $song): BinaryFileResponse
|
||||||
{
|
{
|
||||||
if ($this->countSongLabels($song) === 0) {
|
if ($song->groups()->count() === 0) {
|
||||||
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
|
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$exportService = app(ProExportService::class);
|
$exportService = new ProExportService;
|
||||||
$tempPath = $exportService->generateProFile($song);
|
$tempPath = $exportService->generateProFile($song);
|
||||||
|
|
||||||
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||||
|
|
||||||
return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
|
return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function countSongLabels(Song $song): int
|
|
||||||
{
|
|
||||||
return $song->arrangements()
|
|
||||||
->withCount('arrangementLabels')
|
|
||||||
->get()
|
|
||||||
->sum('arrangement_labels_count');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceAgendaItem;
|
use App\Models\ServiceAgendaItem;
|
||||||
use App\Models\ServiceMacroOverride;
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Slide;
|
use App\Models\Slide;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Services\AgendaMatcherService;
|
use App\Services\AgendaMatcherService;
|
||||||
use App\Services\MacroResolutionService;
|
|
||||||
use App\Services\ProBundleExportService;
|
use App\Services\ProBundleExportService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
@ -126,23 +124,23 @@ public function index(): Response
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Service $service, \App\Services\ServiceImageResolver $imageResolver): Response
|
public function edit(Service $service): Response
|
||||||
{
|
{
|
||||||
$service->load([
|
$service->load([
|
||||||
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
||||||
'serviceSongs.song',
|
'serviceSongs.song',
|
||||||
'serviceSongs.song.arrangements.arrangementSections.section.label',
|
'serviceSongs.song.groups',
|
||||||
|
'serviceSongs.song.arrangements.arrangementGroups.group',
|
||||||
'serviceSongs.arrangement',
|
'serviceSongs.arrangement',
|
||||||
'slides',
|
'slides',
|
||||||
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
||||||
'agendaItems.slides',
|
'agendaItems.slides',
|
||||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
|
'agendaItems.serviceSong.song.groups.slides',
|
||||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
|
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
|
||||||
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
|
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$songsCatalog = Song::query()
|
$songsCatalog = Song::query()
|
||||||
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')])
|
|
||||||
->orderBy('title')
|
->orderBy('title')
|
||||||
->get(['id', 'title', 'ccli_id', 'has_translation'])
|
->get(['id', 'title', 'ccli_id', 'has_translation'])
|
||||||
->map(fn (Song $song) => [
|
->map(fn (Song $song) => [
|
||||||
|
|
@ -150,7 +148,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'title' => $song->title,
|
'title' => $song->title,
|
||||||
'ccli_id' => $song->ccli_id,
|
'ccli_id' => $song->ccli_id,
|
||||||
'has_translation' => $song->has_translation,
|
'has_translation' => $song->has_translation,
|
||||||
'has_content' => (int) $song->content_slides_count > 0,
|
|
||||||
])
|
])
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
|
|
@ -230,41 +227,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
return $arr;
|
return $arr;
|
||||||
}, $filteredItems);
|
}, $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', [
|
return Inertia::render('Services/Edit', [
|
||||||
'service' => [
|
'service' => [
|
||||||
'id' => $service->id,
|
'id' => $service->id,
|
||||||
|
|
@ -275,14 +237,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'finalized_at' => $service->finalized_at?->toJSON(),
|
'finalized_at' => $service->finalized_at?->toJSON(),
|
||||||
'last_synced_at' => $service->last_synced_at?->toJSON(),
|
'last_synced_at' => $service->last_synced_at?->toJSON(),
|
||||||
'has_agenda' => $service->has_agenda,
|
'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) => [
|
'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
|
||||||
'id' => $ss->id,
|
'id' => $ss->id,
|
||||||
|
|
@ -299,7 +253,15 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'title' => $ss->song->title,
|
'title' => $ss->song->title,
|
||||||
'ccli_id' => $ss->song->ccli_id,
|
'ccli_id' => $ss->song->ccli_id,
|
||||||
'has_translation' => $ss->song->has_translation,
|
'has_translation' => $ss->song->has_translation,
|
||||||
'groups' => $this->collectSongLabels($ss->song),
|
'groups' => $ss->song->groups
|
||||||
|
->sortBy('order')
|
||||||
|
->values()
|
||||||
|
->map(fn ($group) => [
|
||||||
|
'id' => $group->id,
|
||||||
|
'name' => $group->name,
|
||||||
|
'color' => $group->color,
|
||||||
|
'order' => $group->order,
|
||||||
|
]),
|
||||||
'arrangements' => $ss->song->arrangements
|
'arrangements' => $ss->song->arrangements
|
||||||
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
|
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
|
||||||
->values()
|
->values()
|
||||||
|
|
@ -307,14 +269,13 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'id' => $arrangement->id,
|
'id' => $arrangement->id,
|
||||||
'name' => $arrangement->name,
|
'name' => $arrangement->name,
|
||||||
'is_default' => $arrangement->is_default,
|
'is_default' => $arrangement->is_default,
|
||||||
'groups' => $arrangement->arrangementSections
|
'groups' => $arrangement->arrangementGroups
|
||||||
->sortBy('order')
|
->sortBy('order')
|
||||||
->values()
|
->values()
|
||||||
->map(fn ($arrangementSection) => [
|
->map(fn ($arrangementGroup) => [
|
||||||
'id' => $arrangementSection->section?->label?->id,
|
'id' => $arrangementGroup->group?->id,
|
||||||
'section_id' => $arrangementSection->section?->id,
|
'name' => $arrangementGroup->group?->name,
|
||||||
'name' => $arrangementSection->section?->label?->name,
|
'color' => $arrangementGroup->group?->color,
|
||||||
'color' => $arrangementSection->section?->label?->color,
|
|
||||||
])
|
])
|
||||||
->filter(fn ($group) => $group['id'] !== null)
|
->filter(fn ($group) => $group['id'] !== null)
|
||||||
->values(),
|
->values(),
|
||||||
|
|
@ -341,7 +302,6 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'title' => $nextService->title,
|
'title' => $nextService->title,
|
||||||
'date' => $nextService->date?->toDateString(),
|
'date' => $nextService->date?->toDateString(),
|
||||||
] : null,
|
] : null,
|
||||||
'macros_per_part' => $macros_per_part,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,38 +322,12 @@ public function finalize(Service $service): JsonResponse
|
||||||
'finalized_at' => now(),
|
'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([
|
return response()->json([
|
||||||
'needs_confirmation' => false,
|
'needs_confirmation' => false,
|
||||||
'success' => 'Service wurde abgeschlossen.',
|
'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
|
public function reopen(Service $service): RedirectResponse
|
||||||
{
|
{
|
||||||
$service->update([
|
$service->update([
|
||||||
|
|
@ -478,26 +412,4 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
|
||||||
)
|
)
|
||||||
->deleteFileAfterSend(true);
|
->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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,70 +2,44 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
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 App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class SettingsController extends Controller
|
class SettingsController extends Controller
|
||||||
{
|
{
|
||||||
private const AGENDA_KEYS = [
|
private const MACRO_KEYS = [
|
||||||
|
'macro_name',
|
||||||
|
'macro_uuid',
|
||||||
|
'macro_collection_name',
|
||||||
|
'macro_collection_uuid',
|
||||||
'agenda_start_title',
|
'agenda_start_title',
|
||||||
'agenda_end_title',
|
'agenda_end_title',
|
||||||
'agenda_announcement_position',
|
'agenda_announcement_position',
|
||||||
'agenda_sermon_matching',
|
'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
|
public function index(): Response
|
||||||
{
|
{
|
||||||
$settings = [];
|
$settings = [];
|
||||||
foreach (self::AGENDA_KEYS as $key) {
|
foreach (self::MACRO_KEYS as $key) {
|
||||||
$settings[$key] = Setting::get($key);
|
$settings[$key] = Setting::get($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Inertia::render('Settings', [
|
return Inertia::render('Settings', [
|
||||||
'settings' => $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
|
public function update(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'key' => ['required', 'string', Rule::in(self::AGENDA_KEYS)],
|
'key' => ['required', 'string', 'in:'.implode(',', self::MACRO_KEYS)],
|
||||||
'value' => ['nullable', 'string', 'max:500'],
|
'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']);
|
Setting::set($validated['key'], $validated['value']);
|
||||||
|
|
||||||
return response()->json(['success' => true]);
|
return response()->json(['success' => true]);
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,6 @@ private function handleImage(
|
||||||
'original_filename' => $file->getClientOriginalName(),
|
'original_filename' => $file->getClientOriginalName(),
|
||||||
'stored_filename' => $result['filename'],
|
'stored_filename' => $result['filename'],
|
||||||
'thumbnail_filename' => $result['thumbnail'],
|
'thumbnail_filename' => $result['thumbnail'],
|
||||||
'cover_mode' => $result['fullCover'] ?? null,
|
|
||||||
'expire_date' => $expireDate,
|
'expire_date' => $expireDate,
|
||||||
'uploader_name' => $uploaderName,
|
'uploader_name' => $uploaderName,
|
||||||
'uploaded_at' => now(),
|
'uploaded_at' => now(),
|
||||||
|
|
@ -261,7 +260,6 @@ private function handleZip(
|
||||||
'original_filename' => $file->getClientOriginalName(),
|
'original_filename' => $file->getClientOriginalName(),
|
||||||
'stored_filename' => $result['filename'],
|
'stored_filename' => $result['filename'],
|
||||||
'thumbnail_filename' => $result['thumbnail'],
|
'thumbnail_filename' => $result['thumbnail'],
|
||||||
'cover_mode' => $result['fullCover'] ?? null,
|
|
||||||
'expire_date' => $expireDate,
|
'expire_date' => $expireDate,
|
||||||
'uploader_name' => $uploaderName,
|
'uploader_name' => $uploaderName,
|
||||||
'uploaded_at' => now(),
|
'uploaded_at' => now(),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\SongRequest;
|
use App\Http\Requests\SongRequest;
|
||||||
use App\Models\Label;
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Services\SongService;
|
use App\Services\SongService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -16,10 +15,12 @@ public function __construct(
|
||||||
private readonly SongService $songService,
|
private readonly SongService $songService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Songs auflisten (paginiert, durchsuchbar).
|
||||||
|
*/
|
||||||
public function index(Request $request): JsonResponse
|
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')) {
|
if ($search = $request->input('search')) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
|
|
@ -28,12 +29,6 @@ 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')
|
$songs = $query->orderBy('title')
|
||||||
->paginate($request->input('per_page', 20));
|
->paginate($request->input('per_page', 20));
|
||||||
|
|
||||||
|
|
@ -44,7 +39,6 @@ public function index(Request $request): JsonResponse
|
||||||
'ccli_id' => $song->ccli_id,
|
'ccli_id' => $song->ccli_id,
|
||||||
'author' => $song->author,
|
'author' => $song->author,
|
||||||
'has_translation' => $song->has_translation,
|
'has_translation' => $song->has_translation,
|
||||||
'has_content' => (int) $song->content_slides_count > 0,
|
|
||||||
'last_used_at' => $song->last_used_at?->toDateString(),
|
'last_used_at' => $song->last_used_at?->toDateString(),
|
||||||
'last_used_in_service' => $song->last_used_in_service,
|
'last_used_in_service' => $song->last_used_in_service,
|
||||||
'created_at' => $song->created_at->toDateTimeString(),
|
'created_at' => $song->created_at->toDateTimeString(),
|
||||||
|
|
@ -59,11 +53,15 @@ public function index(Request $request): JsonResponse
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
|
||||||
|
*/
|
||||||
public function store(SongRequest $request): JsonResponse
|
public function store(SongRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$song = DB::transaction(function () use ($request) {
|
$song = DB::transaction(function () use ($request) {
|
||||||
$song = Song::create($request->validated());
|
$song = Song::create($request->validated());
|
||||||
|
|
||||||
|
$this->songService->createDefaultGroups($song);
|
||||||
$this->songService->createDefaultArrangement($song);
|
$this->songService->createDefaultArrangement($song);
|
||||||
|
|
||||||
return $song;
|
return $song;
|
||||||
|
|
@ -71,13 +69,16 @@ public function store(SongRequest $request): JsonResponse
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Song erfolgreich erstellt',
|
'message' => 'Song erfolgreich erstellt',
|
||||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Song mit Gruppen, Slides und Arrangements anzeigen.
|
||||||
|
*/
|
||||||
public function show(int $id): JsonResponse
|
public function show(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$song = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($id);
|
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
|
||||||
|
|
||||||
if (! $song) {
|
if (! $song) {
|
||||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||||
|
|
@ -88,6 +89,9 @@ public function show(int $id): JsonResponse
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Song-Metadaten aktualisieren.
|
||||||
|
*/
|
||||||
public function update(SongRequest $request, int $id): JsonResponse
|
public function update(SongRequest $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$song = Song::find($id);
|
$song = Song::find($id);
|
||||||
|
|
@ -100,10 +104,13 @@ public function update(SongRequest $request, int $id): JsonResponse
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Song erfolgreich aktualisiert',
|
'message' => 'Song erfolgreich aktualisiert',
|
||||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Song soft-löschen.
|
||||||
|
*/
|
||||||
public function destroy(int $id): JsonResponse
|
public function destroy(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$song = Song::find($id);
|
$song = Song::find($id);
|
||||||
|
|
@ -119,37 +126,11 @@ public function destroy(int $id): JsonResponse
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function formatSongDetail(Song $song): array
|
/**
|
||||||
|
* Song-Detail formatieren.
|
||||||
|
*/
|
||||||
|
private 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 [
|
return [
|
||||||
'id' => $song->id,
|
'id' => $song->id,
|
||||||
'title' => $song->title,
|
'title' => $song->title,
|
||||||
|
|
@ -163,25 +144,27 @@ public function formatSongDetail(Song $song): array
|
||||||
'last_used_in_service' => $song->last_used_in_service,
|
'last_used_in_service' => $song->last_used_in_service,
|
||||||
'created_at' => $song->created_at->toDateTimeString(),
|
'created_at' => $song->created_at->toDateTimeString(),
|
||||||
'updated_at' => $song->updated_at->toDateTimeString(),
|
'updated_at' => $song->updated_at->toDateTimeString(),
|
||||||
'groups' => $groupsPayload,
|
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
|
||||||
'available_labels' => Label::query()
|
'id' => $group->id,
|
||||||
->whereNull('hidden_at')
|
'name' => $group->name,
|
||||||
->orderBy('name')
|
'color' => $group->color,
|
||||||
->get(['id', 'name', 'color'])
|
'order' => $group->order,
|
||||||
->map(fn (Label $label) => [
|
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
|
||||||
'id' => $label->id,
|
'id' => $slide->id,
|
||||||
'name' => $label->name,
|
'order' => $slide->order,
|
||||||
'color' => $label->color,
|
'text_content' => $slide->text_content,
|
||||||
|
'text_content_translated' => $slide->text_content_translated,
|
||||||
|
'notes' => $slide->notes,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
])->toArray(),
|
||||||
'arrangements' => $song->arrangements->map(fn ($arr) => [
|
'arrangements' => $song->arrangements->map(fn ($arr) => [
|
||||||
'id' => $arr->id,
|
'id' => $arr->id,
|
||||||
'name' => $arr->name,
|
'name' => $arr->name,
|
||||||
'is_default' => $arr->is_default,
|
'is_default' => $arr->is_default,
|
||||||
'arrangement_groups' => $arr->arrangementSections->sortBy('order')->values()->map(fn ($arrangementSection) => [
|
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
|
||||||
'id' => $arrangementSection->id,
|
'id' => $ag->id,
|
||||||
'section_id' => $arrangementSection->song_section_id,
|
'song_group_id' => $ag->song_group_id,
|
||||||
'label_id' => $arrangementSection->section?->label_id,
|
'order' => $ag->order,
|
||||||
'order' => $arrangementSection->order,
|
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -57,27 +57,21 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
|
||||||
private function buildGroupsInOrder(SongArrangement $arrangement): array
|
private function buildGroupsInOrder(SongArrangement $arrangement): array
|
||||||
{
|
{
|
||||||
$arrangement->load([
|
$arrangement->load([
|
||||||
'arrangementSections' => fn ($query) => $query->orderBy('order'),
|
'arrangementGroups' => fn ($query) => $query->orderBy('order'),
|
||||||
'arrangementSections.section.slides' => fn ($query) => $query->orderBy('order'),
|
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
|
||||||
'arrangementSections.section.label',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $arrangement->arrangementSections->map(function ($arrangementSection) {
|
return $arrangement->arrangementGroups->map(function ($arrangementGroup) {
|
||||||
$section = $arrangementSection->section;
|
$group = $arrangementGroup->group;
|
||||||
$label = $section?->label;
|
|
||||||
|
|
||||||
if ($section === null || $label === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $label->name,
|
'name' => $group->name,
|
||||||
'color' => $label->color ?? '#6b7280',
|
'color' => $group->color ?? '#6b7280',
|
||||||
'slides' => $section->slides->map(fn ($slide) => [
|
'slides' => $group->slides->map(fn ($slide) => [
|
||||||
'text_content' => $slide->text_content,
|
'text_content' => $slide->text_content,
|
||||||
'text_content_translated' => $slide->text_content_translated,
|
'text_content_translated' => $slide->text_content_translated,
|
||||||
])->values()->all(),
|
])->values()->all(),
|
||||||
];
|
];
|
||||||
})->filter()->values()->all();
|
})->values()->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
<?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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,52 +18,41 @@ public function __construct(
|
||||||
public function page(Song $song): Response
|
public function page(Song $song): Response
|
||||||
{
|
{
|
||||||
$song->load([
|
$song->load([
|
||||||
'arrangements' => fn ($q) => $q->where('is_default', true),
|
'groups' => fn ($query) => $query
|
||||||
'arrangements.arrangementSections' => fn ($q) => $q->orderBy('order'),
|
->orderBy('order')
|
||||||
'arrangements.arrangementSections.section.slides',
|
->with([
|
||||||
'arrangements.arrangementSections.section.label',
|
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$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', [
|
return Inertia::render('Songs/Translate', [
|
||||||
'song' => [
|
'song' => [
|
||||||
'id' => $song->id,
|
'id' => $song->id,
|
||||||
'title' => $song->title,
|
'title' => $song->title,
|
||||||
'ccli_id' => $song->ccli_id,
|
'ccli_id' => $song->ccli_id,
|
||||||
'has_translation' => $song->has_translation,
|
'has_translation' => $song->has_translation,
|
||||||
'groups' => $groups,
|
'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(),
|
||||||
],
|
],
|
||||||
'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
|
public function fetchUrl(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
|
|
@ -83,6 +72,11 @@ 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
|
public function import(int $songId, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$song = Song::find($songId);
|
$song = Song::find($songId);
|
||||||
|
|
@ -104,6 +98,9 @@ public function import(int $songId, Request $request): JsonResponse
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzung eines Songs komplett entfernen.
|
||||||
|
*/
|
||||||
public function destroy(int $songId): JsonResponse
|
public function destroy(int $songId): JsonResponse
|
||||||
{
|
{
|
||||||
$song = Song::find($songId);
|
$song = Song::find($songId);
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,6 @@ public function share(Request $request): array
|
||||||
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
|
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
|
||||||
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
|
'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'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,11 +17,7 @@ class Service extends Model
|
||||||
'title',
|
'title',
|
||||||
'date',
|
'date',
|
||||||
'preacher_name',
|
'preacher_name',
|
||||||
'preacher_name_override',
|
|
||||||
'beamer_tech_name',
|
'beamer_tech_name',
|
||||||
'key_visual_filename',
|
|
||||||
'background_filename',
|
|
||||||
'moderator_name',
|
|
||||||
'finalized_at',
|
'finalized_at',
|
||||||
'last_synced_at',
|
'last_synced_at',
|
||||||
'cts_data',
|
'cts_data',
|
||||||
|
|
@ -49,16 +45,6 @@ public function slides(): HasMany
|
||||||
return $this->hasMany(Slide::class);
|
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
|
public function agendaItems(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');
|
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,7 +19,6 @@ class Slide extends Model
|
||||||
'original_filename',
|
'original_filename',
|
||||||
'stored_filename',
|
'stored_filename',
|
||||||
'thumbnail_filename',
|
'thumbnail_filename',
|
||||||
'cover_mode',
|
|
||||||
'expire_date',
|
'expire_date',
|
||||||
'uploader_name',
|
'uploader_name',
|
||||||
'uploaded_at',
|
'uploaded_at',
|
||||||
|
|
@ -31,7 +30,6 @@ protected function casts(): array
|
||||||
return [
|
return [
|
||||||
'expire_date' => 'date',
|
'expire_date' => 'date',
|
||||||
'uploaded_at' => 'datetime',
|
'uploaded_at' => 'datetime',
|
||||||
'cover_mode' => 'boolean',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ class Song extends Model
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'ccli_id',
|
'ccli_id',
|
||||||
'cts_song_id',
|
'cts_song_id',
|
||||||
'imported_from_ccli_at',
|
|
||||||
'ccli_source_url',
|
|
||||||
'title',
|
'title',
|
||||||
'author',
|
'author',
|
||||||
'copyright_text',
|
'copyright_text',
|
||||||
|
|
@ -32,20 +30,19 @@ protected function casts(): array
|
||||||
return [
|
return [
|
||||||
'has_translation' => 'boolean',
|
'has_translation' => 'boolean',
|
||||||
'last_used_at' => 'datetime',
|
'last_used_at' => 'datetime',
|
||||||
'imported_from_ccli_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function groups(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SongGroup::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function arrangements(): HasMany
|
public function arrangements(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(SongArrangement::class);
|
return $this->hasMany(SongArrangement::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sections(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(SongSection::class)->orderBy('order');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function serviceSongs(): HasMany
|
public function serviceSongs(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ServiceSong::class);
|
return $this->hasMany(ServiceSong::class);
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,9 @@ public function song(): BelongsTo
|
||||||
return $this->belongsTo(Song::class);
|
return $this->belongsTo(Song::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function arrangementLabels(): HasMany
|
public function arrangementGroups(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
|
return $this->hasMany(SongArrangementGroup::class);
|
||||||
}
|
|
||||||
|
|
||||||
public function arrangementSections(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(SongArrangementSection::class, 'song_arrangement_id')->orderBy('order');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function serviceSongs(): HasMany
|
public function serviceSongs(): HasMany
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,13 @@
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class SongArrangementSection extends Model
|
class SongArrangementGroup extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $table = 'song_arrangement_labels';
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'song_arrangement_id',
|
'song_arrangement_id',
|
||||||
'song_section_id',
|
'song_group_id',
|
||||||
'order',
|
'order',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -23,8 +21,8 @@ public function arrangement(): BelongsTo
|
||||||
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
|
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function section(): BelongsTo
|
public function group(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(SongSection::class, 'song_section_id');
|
return $this->belongsTo(SongGroup::class, 'song_group_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
class SongArrangementLabel extends SongArrangementSection {}
|
|
||||||
|
|
@ -7,35 +7,29 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class SongSection extends Model
|
class SongGroup extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'song_id',
|
'song_id',
|
||||||
'label_id',
|
'name',
|
||||||
|
'color',
|
||||||
'order',
|
'order',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'order' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function song(): BelongsTo
|
public function song(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Song::class);
|
return $this->belongsTo(Song::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function label(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Label::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function slides(): HasMany
|
public function slides(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(SongSlide::class, 'song_section_id')->orderBy('order');
|
return $this->hasMany(SongSlide::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function arrangementGroups(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SongArrangementGroup::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11,15 +11,15 @@ class SongSlide extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'song_section_id',
|
'song_group_id',
|
||||||
'order',
|
'order',
|
||||||
'text_content',
|
'text_content',
|
||||||
'text_content_translated',
|
'text_content_translated',
|
||||||
'notes',
|
'notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function section(): BelongsTo
|
public function group(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(SongSection::class, 'song_section_id');
|
return $this->belongsTo(SongGroup::class, 'song_group_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Exceptions\DuplicateCcliSongException;
|
|
||||||
use App\Models\ApiRequestLog;
|
|
||||||
use App\Models\Label;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\Song;
|
|
||||||
use App\Models\SongArrangement;
|
|
||||||
use App\Models\SongArrangementLabel;
|
|
||||||
use App\Models\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)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Services\DTO\ParsedCcliSection;
|
|
||||||
use App\Services\DTO\ParsedCcliSong;
|
|
||||||
use App\Support\CcliLabels;
|
|
||||||
use Closure;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class CcliPasteParser
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ?Closure $sectionDetector = null,
|
|
||||||
private readonly ?Closure $metadataDetector = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function parse(string $rawText): ParsedCcliSong
|
|
||||||
{
|
|
||||||
if (trim($rawText) === '') {
|
|
||||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = array_map(
|
|
||||||
fn (string $line): string => trim($line),
|
|
||||||
preg_split('/\r\n|\n|\r/', $rawText) ?: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$isSectionLabel = $this->sectionDetector ?? fn (string $line): bool => CcliLabels::isSectionLabel($line);
|
|
||||||
$isMetadataLine = $this->metadataDetector ?? fn (string $line): bool => CcliLabels::isMetadataLine($line);
|
|
||||||
|
|
||||||
$firstSectionIndex = null;
|
|
||||||
foreach ($lines as $index => $line) {
|
|
||||||
if ($line !== '' && $isSectionLabel($line)) {
|
|
||||||
$firstSectionIndex = $index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($firstSectionIndex === null) {
|
|
||||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$headerLines = array_values(array_filter(
|
|
||||||
array_slice($lines, 0, $firstSectionIndex),
|
|
||||||
fn (string $line): bool => $line !== '',
|
|
||||||
));
|
|
||||||
|
|
||||||
$title = $headerLines[0] ?? '';
|
|
||||||
$author = $headerLines[1] ?? null;
|
|
||||||
$ccliId = null;
|
|
||||||
$year = null;
|
|
||||||
$copyrightText = null;
|
|
||||||
$sections = [];
|
|
||||||
$current = null;
|
|
||||||
$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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Song;
|
|
||||||
use App\Models\SongArrangement;
|
|
||||||
use App\Services\DTO\ParsedCcliSection;
|
|
||||||
use App\Support\CcliLabels;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
final class CcliTranslationPairingService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CcliPasteParser $parser,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pair a CCLI paste as translation for an existing local song.
|
|
||||||
* Returns mapping for UI review (does NOT save to DB).
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* song: Song,
|
|
||||||
* mapping: array<int, array{local_label: string, ccli_label: string|null, distributed_lines: string[]}>,
|
|
||||||
* unmatched_labels: string[],
|
|
||||||
* distributed_text: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function pair(Song $localSong, string $ccliRawText, string $arrangementName = 'normal'): array
|
|
||||||
{
|
|
||||||
$parsed = $this->parser->parse($ccliRawText);
|
|
||||||
|
|
||||||
$localSong->loadMissing(['arrangements.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,7 +28,8 @@ public function __construct(
|
||||||
private readonly ?Closure $songFetcher = null,
|
private readonly ?Closure $songFetcher = null,
|
||||||
private readonly ?Closure $agendaFetcher = null,
|
private readonly ?Closure $agendaFetcher = null,
|
||||||
private readonly ?Closure $eventServiceFetcher = null,
|
private readonly ?Closure $eventServiceFetcher = null,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function sync(): array
|
public function sync(): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\DTO;
|
|
||||||
|
|
||||||
final class LabelImportResult
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $newCount,
|
|
||||||
public readonly int $updatedCount,
|
|
||||||
public readonly int $totalInFile,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\DTO;
|
|
||||||
|
|
||||||
final readonly class ParsedCcliSection
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $label,
|
|
||||||
public string $kind,
|
|
||||||
public ?string $number,
|
|
||||||
public ?string $modifier,
|
|
||||||
/** @var string[] */
|
|
||||||
public array $lines,
|
|
||||||
/** @var string[]|null */
|
|
||||||
public ?array $linesTranslated = null,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\DTO;
|
|
||||||
|
|
||||||
final readonly class ParsedCcliSong
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $title,
|
|
||||||
public ?string $author,
|
|
||||||
public ?string $ccliId,
|
|
||||||
public ?string $year,
|
|
||||||
public ?string $copyrightText,
|
|
||||||
public ?string $sourceUrl,
|
|
||||||
/** @var ParsedCcliSection[] */
|
|
||||||
public array $sections,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -56,45 +56,6 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array
|
||||||
'filename' => $relativePath,
|
'filename' => $relativePath,
|
||||||
'thumbnail' => $thumbnailPath,
|
'thumbnail' => $thumbnailPath,
|
||||||
'warnings' => $warnings,
|
'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,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,11 +143,11 @@ public function processZip(UploadedFile|string|SplFileInfo $file): array
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateThumbnail(string $path, string $disk = 'public'): string
|
public function generateThumbnail(string $path): string
|
||||||
{
|
{
|
||||||
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
|
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||||
? $path
|
? $path
|
||||||
: Storage::disk($disk)->path($path);
|
: Storage::disk('public')->path($path);
|
||||||
|
|
||||||
if (! is_file($absolutePath)) {
|
if (! is_file($absolutePath)) {
|
||||||
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
|
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
|
||||||
|
|
@ -194,8 +155,8 @@ public function generateThumbnail(string $path, string $disk = 'public'): string
|
||||||
|
|
||||||
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
|
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
|
||||||
$thumbnailRelativePath = 'slides/thumbnails/'.$filename;
|
$thumbnailRelativePath = 'slides/thumbnails/'.$filename;
|
||||||
$thumbnailAbsolutePath = Storage::disk($disk)->path($thumbnailRelativePath);
|
$thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath);
|
||||||
Storage::disk($disk)->makeDirectory('slides/thumbnails');
|
Storage::disk('public')->makeDirectory('slides/thumbnails');
|
||||||
$this->ensureDirectory(dirname($thumbnailAbsolutePath));
|
$this->ensureDirectory(dirname($thumbnailAbsolutePath));
|
||||||
|
|
||||||
$manager = $this->createImageManager();
|
$manager = $this->createImageManager();
|
||||||
|
|
@ -318,20 +279,6 @@ private function checkImageDimensions(int $width, int $height): array
|
||||||
return $warnings;
|
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
|
private function ensureDirectory(string $directory): void
|
||||||
{
|
{
|
||||||
if (is_dir($directory)) {
|
if (is_dir($directory)) {
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
<?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 !== '',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<?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'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,12 +19,7 @@ public function generatePlaylist(Service $service): array
|
||||||
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
||||||
->where('is_before_event', false)
|
->where('is_before_event', false)
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->with([
|
->with(['slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'serviceSong.song.groups.slides', 'serviceSong.arrangement.arrangementGroups.group'])
|
||||||
'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();
|
->get();
|
||||||
|
|
||||||
if ($agendaItems->isEmpty()) {
|
if ($agendaItems->isEmpty()) {
|
||||||
|
|
@ -54,7 +49,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$announcementPatterns = Setting::get('agenda_announcement_position');
|
$announcementPatterns = Setting::get('agenda_announcement_position');
|
||||||
$announcementInserted = false;
|
$announcementInserted = false;
|
||||||
|
|
||||||
$exportService = app(ProExportService::class);
|
$exportService = new ProExportService;
|
||||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||||
mkdir($tempDir, 0755, true);
|
mkdir($tempDir, 0755, true);
|
||||||
|
|
||||||
|
|
@ -62,20 +57,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$embeddedFiles = [];
|
$embeddedFiles = [];
|
||||||
$skippedUnmatched = 0;
|
$skippedUnmatched = 0;
|
||||||
|
|
||||||
$moderatorSlideData = $this->buildModeratorSlideData($service);
|
|
||||||
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
|
|
||||||
|
|
||||||
foreach ($agendaItems as $item) {
|
foreach ($agendaItems as $item) {
|
||||||
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
|
|
||||||
$this->writeProAndEmbed(
|
|
||||||
'Moderator',
|
|
||||||
$moderatorSlideData,
|
|
||||||
$tempDir,
|
|
||||||
$playlistItems,
|
|
||||||
$embeddedFiles,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
|
if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
|
||||||
$patterns = array_map('trim', explode(',', $announcementPatterns));
|
$patterns = array_map('trim', explode(',', $announcementPatterns));
|
||||||
$matcher = app(AgendaMatcherService::class);
|
$matcher = app(AgendaMatcherService::class);
|
||||||
|
|
@ -87,8 +69,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
|
||||||
'information',
|
|
||||||
);
|
);
|
||||||
$announcementInserted = true;
|
$announcementInserted = true;
|
||||||
}
|
}
|
||||||
|
|
@ -100,19 +80,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
if ($serviceSong->song_id && $serviceSong->song) {
|
if ($serviceSong->song_id && $serviceSong->song) {
|
||||||
$song = $serviceSong->song;
|
$song = $serviceSong->song;
|
||||||
|
|
||||||
if ($this->countSongLabels($song) === 0) {
|
if ($song->groups()->count() === 0) {
|
||||||
$skippedUnmatched++;
|
$skippedUnmatched++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proPath = $exportService->generateProFile($song, $service);
|
$proPath = $exportService->generateProFile($song);
|
||||||
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||||
$destPath = $tempDir.'/'.$proFilename;
|
$destPath = $tempDir.'/'.$proFilename;
|
||||||
rename($proPath, $destPath);
|
rename($proPath, $destPath);
|
||||||
|
|
||||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||||
$this->embedBackground($service, $embeddedFiles);
|
|
||||||
|
|
||||||
$playlistItems[] = [
|
$playlistItems[] = [
|
||||||
'type' => 'presentation',
|
'type' => 'presentation',
|
||||||
|
|
@ -127,11 +106,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($item->slides->isNotEmpty()) {
|
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';
|
$label = $item->title ?: 'Folien';
|
||||||
$this->addSlidesFromCollection(
|
$this->addSlidesFromCollection(
|
||||||
$item->slides,
|
$item->slides,
|
||||||
|
|
@ -140,20 +114,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
|
||||||
$this->backgroundPartTypeForAgendaItem($item),
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->isNameTagAgendaItem($item)) {
|
|
||||||
$this->addKeyVisualFallbackPresentation(
|
|
||||||
$item,
|
|
||||||
$service,
|
|
||||||
$tempDir,
|
|
||||||
$playlistItems,
|
|
||||||
$embeddedFiles,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,8 +128,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$prependItems,
|
$prependItems,
|
||||||
$prependFiles,
|
$prependFiles,
|
||||||
$service,
|
|
||||||
'information',
|
|
||||||
);
|
);
|
||||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
||||||
|
|
@ -203,18 +161,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
*/
|
*/
|
||||||
private function generatePlaylistLegacy(Service $service): array
|
private function generatePlaylistLegacy(Service $service): array
|
||||||
{
|
{
|
||||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
|
$service->loadMissing('serviceSongs.song.groups.slides');
|
||||||
|
|
||||||
$matchedSongs = $service->serviceSongs()
|
$matchedSongs = $service->serviceSongs()
|
||||||
->whereNotNull('song_id')
|
->whereNotNull('song_id')
|
||||||
->orderBy('order')
|
->orderBy('order')
|
||||||
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label')
|
->with('song.groups.slides')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||||
$skippedEmpty = 0;
|
$skippedEmpty = 0;
|
||||||
|
|
||||||
$exportService = app(ProExportService::class);
|
$exportService = new ProExportService;
|
||||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||||
mkdir($tempDir, 0755, true);
|
mkdir($tempDir, 0755, true);
|
||||||
|
|
||||||
|
|
@ -233,19 +191,18 @@ private function generatePlaylistLegacy(Service $service): array
|
||||||
foreach ($matchedSongs as $serviceSong) {
|
foreach ($matchedSongs as $serviceSong) {
|
||||||
$song = $serviceSong->song;
|
$song = $serviceSong->song;
|
||||||
|
|
||||||
if (! $song || $this->countSongLabels($song) === 0) {
|
if (! $song || $song->groups()->count() === 0) {
|
||||||
$skippedEmpty++;
|
$skippedEmpty++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proPath = $exportService->generateProFile($song, $service);
|
$proPath = $exportService->generateProFile($song);
|
||||||
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||||
$destPath = $tempDir.'/'.$proFilename;
|
$destPath = $tempDir.'/'.$proFilename;
|
||||||
rename($proPath, $destPath);
|
rename($proPath, $destPath);
|
||||||
|
|
||||||
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
$embeddedFiles[$proFilename] = file_get_contents($destPath);
|
||||||
$this->embedBackground($service, $embeddedFiles);
|
|
||||||
|
|
||||||
$playlistItems[] = [
|
$playlistItems[] = [
|
||||||
'type' => 'presentation',
|
'type' => 'presentation',
|
||||||
|
|
@ -299,13 +256,9 @@ private function addSlidesFromCollection(
|
||||||
string $tempDir,
|
string $tempDir,
|
||||||
array &$playlistItems,
|
array &$playlistItems,
|
||||||
array &$embeddedFiles,
|
array &$embeddedFiles,
|
||||||
?Service $service = null,
|
|
||||||
?string $backgroundPartType = null,
|
|
||||||
): void {
|
): void {
|
||||||
$slideDataList = [];
|
$slideDataList = [];
|
||||||
$imageFiles = [];
|
$imageFiles = [];
|
||||||
$background = $this->backgroundData($service);
|
|
||||||
$backgroundAttached = false;
|
|
||||||
|
|
||||||
foreach ($slides->values() as $index => $slide) {
|
foreach ($slides->values() as $index => $slide) {
|
||||||
$storedPath = Storage::disk('public')->path($slide->stored_filename);
|
$storedPath = Storage::disk('public')->path($slide->stored_filename);
|
||||||
|
|
@ -320,22 +273,11 @@ private function addSlidesFromCollection(
|
||||||
|
|
||||||
$imageFiles[$imageFilename] = file_get_contents($destPath);
|
$imageFiles[$imageFilename] = file_get_contents($destPath);
|
||||||
|
|
||||||
$singleSlideData = [
|
$slideDataList[] = [
|
||||||
'media' => $imageFilename,
|
'media' => $imageFilename,
|
||||||
'format' => 'JPG',
|
'format' => 'JPG',
|
||||||
'label' => $slide->original_filename,
|
'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)) {
|
if (empty($slideDataList)) {
|
||||||
|
|
@ -416,262 +358,14 @@ private function addSlidePresentation(
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$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
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||||
{
|
{
|
||||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
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
|
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||||
{
|
{
|
||||||
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceAgendaItem;
|
use App\Models\ServiceAgendaItem;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\Slide;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use ProPresenter\Parser\PresentationBundle;
|
use ProPresenter\Parser\PresentationBundle;
|
||||||
|
|
@ -17,11 +15,6 @@ class ProBundleExportService
|
||||||
{
|
{
|
||||||
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
|
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
|
public function generateBundle(Service $service, string $blockType): string
|
||||||
{
|
{
|
||||||
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
|
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
|
||||||
|
|
@ -35,16 +28,15 @@ public function generateBundle(Service $service, string $blockType): string
|
||||||
|
|
||||||
$groupName = ucfirst($blockType);
|
$groupName = ucfirst($blockType);
|
||||||
|
|
||||||
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
|
return $this->buildBundleFromSlides($slides, $groupName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||||
{
|
{
|
||||||
$agendaItem->loadMissing([
|
$agendaItem->loadMissing([
|
||||||
'service',
|
|
||||||
'slides',
|
'slides',
|
||||||
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
'serviceSong.song.groups.slides',
|
||||||
'serviceSong.song.arrangements.arrangementSections.section.label',
|
'serviceSong.song.arrangements.arrangementGroups.group',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$title = $agendaItem->title ?: 'Ablauf-Element';
|
$title = $agendaItem->title ?: 'Ablauf-Element';
|
||||||
|
|
@ -52,22 +44,14 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||||
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
||||||
$song = $agendaItem->serviceSong->song;
|
$song = $agendaItem->serviceSong->song;
|
||||||
|
|
||||||
$labelCount = $song->arrangements()
|
if ($song->groups()->count() === 0) {
|
||||||
->withCount('arrangementLabels')
|
|
||||||
->get()
|
|
||||||
->sum('arrangement_labels_count');
|
|
||||||
|
|
||||||
if ($labelCount === 0) {
|
|
||||||
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
|
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
$parserSong = (new ProExportService)->generateParserSong($song);
|
||||||
$proFilename = self::safeFilename($song->title).'.pro';
|
$proFilename = self::safeFilename($song->title).'.pro';
|
||||||
|
|
||||||
$songMediaFiles = [];
|
$bundle = new PresentationBundle($parserSong, $proFilename);
|
||||||
$this->embedBackground($agendaItem->service, $songMediaFiles);
|
|
||||||
|
|
||||||
$bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles);
|
|
||||||
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
|
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
|
||||||
ProBundleWriter::write($bundle, $bundlePath);
|
ProBundleWriter::write($bundle, $bundlePath);
|
||||||
|
|
||||||
|
|
@ -79,27 +63,14 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return $this->buildBundleFromSlides(
|
return $this->buildBundleFromSlides($slides, $title);
|
||||||
$slides,
|
|
||||||
$title,
|
|
||||||
$agendaItem->service,
|
|
||||||
'agenda_item',
|
|
||||||
$this->backgroundPartTypeForAgendaItem($agendaItem),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
||||||
private function buildBundleFromSlides(
|
private function buildBundleFromSlides($slides, string $groupName): string
|
||||||
$slides,
|
{
|
||||||
string $groupName,
|
|
||||||
?Service $service = null,
|
|
||||||
?string $partType = null,
|
|
||||||
?string $backgroundPartType = null,
|
|
||||||
): string {
|
|
||||||
$slideData = [];
|
$slideData = [];
|
||||||
$mediaFiles = [];
|
$mediaFiles = [];
|
||||||
$background = $this->backgroundData($service);
|
|
||||||
$backgroundAttached = false;
|
|
||||||
|
|
||||||
foreach ($slides as $slide) {
|
foreach ($slides as $slide) {
|
||||||
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
||||||
|
|
@ -115,37 +86,11 @@ private function buildBundleFromSlides(
|
||||||
|
|
||||||
$mediaFiles[$imageFilename] = $imageContent;
|
$mediaFiles[$imageFilename] = $imageContent;
|
||||||
|
|
||||||
$singleSlideData = [
|
$slideData[] = [
|
||||||
'media' => $imageFilename,
|
'media' => $imageFilename,
|
||||||
'format' => 'JPG',
|
'format' => 'JPG',
|
||||||
'label' => $slide->original_filename,
|
'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 = [
|
$groups = [
|
||||||
|
|
@ -173,83 +118,6 @@ private function buildBundleFromSlides(
|
||||||
return $bundlePath;
|
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
|
private static function safeFilename(string $name): string
|
||||||
{
|
{
|
||||||
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';
|
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,20 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Setting;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use ProPresenter\Parser\ProFileGenerator;
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
|
|
||||||
class ProExportService
|
class ProExportService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function generateProFile(Song $song): string
|
||||||
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';
|
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
|
||||||
|
|
||||||
ProFileGenerator::generateAndWrite(
|
ProFileGenerator::generateAndWrite(
|
||||||
$tempPath,
|
$tempPath,
|
||||||
$song->title,
|
$song->title,
|
||||||
$this->buildGroups($song, $service),
|
$this->buildGroups($song),
|
||||||
$this->buildArrangements($song),
|
$this->buildArrangements($song),
|
||||||
$this->buildCcliMetadata($song),
|
$this->buildCcliMetadata($song),
|
||||||
);
|
);
|
||||||
|
|
@ -28,79 +23,44 @@ public function generateProFile(Song $song, ?Service $service = null): string
|
||||||
return $tempPath;
|
return $tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
|
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
|
||||||
{
|
{
|
||||||
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
|
||||||
|
|
||||||
return ProFileGenerator::generate(
|
return ProFileGenerator::generate(
|
||||||
$song->title,
|
$song->title,
|
||||||
$this->buildGroups($song, $service),
|
$this->buildGroups($song),
|
||||||
$this->buildArrangements($song),
|
$this->buildArrangements($song),
|
||||||
$this->buildCcliMetadata($song),
|
$this->buildCcliMetadata($song),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildGroups(Song $song, ?Service $service = null): array
|
private function buildGroups(Song $song): array
|
||||||
{
|
{
|
||||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
|
||||||
|
|
||||||
if ($defaultArr === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
|
|
||||||
|
|
||||||
$groups = [];
|
$groups = [];
|
||||||
$seenSectionIds = [];
|
$macroData = $this->buildMacroData();
|
||||||
$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 = [];
|
$slides = [];
|
||||||
$sectionSlides = $section->slides->sortBy('order')->values();
|
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
|
||||||
$totalSlides = $sectionSlides->count();
|
|
||||||
|
|
||||||
foreach ($sectionSlides as $slideIndex => $slide) {
|
foreach ($group->slides->sortBy('order') as $slide) {
|
||||||
$slideData = ['text' => $slide->text_content ?? ''];
|
$slideData = ['text' => $slide->text_content ?? ''];
|
||||||
|
|
||||||
if ($slide->text_content_translated) {
|
if ($slide->text_content_translated) {
|
||||||
$slideData['translation'] = $slide->text_content_translated;
|
$slideData['translation'] = $slide->text_content_translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
|
if ($isCopyrightGroup && $macroData) {
|
||||||
$slideData['background'] = $background;
|
$slideData['macro'] = $macroData;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
$slides[] = $slideData;
|
||||||
}
|
}
|
||||||
|
|
||||||
$groups[] = [
|
$groups[] = [
|
||||||
'name' => $label->name,
|
'name' => $group->name,
|
||||||
'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
|
'color' => ProImportService::hexToRgba($group->color),
|
||||||
'slides' => $slides,
|
'slides' => $slides,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -108,46 +68,32 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
||||||
return $groups;
|
return $groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function backgroundData(?Service $service): ?array
|
private function buildMacroData(): ?array
|
||||||
{
|
{
|
||||||
if ($service === null) {
|
$name = Setting::get('macro_name');
|
||||||
return null;
|
$uuid = Setting::get('macro_uuid');
|
||||||
}
|
|
||||||
|
|
||||||
$background = $this->imageResolver->backgroundFor($service);
|
if (! $name || ! $uuid) {
|
||||||
|
|
||||||
if ($background === null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
|
'name' => $name,
|
||||||
'format' => 'JPG',
|
'uuid' => $uuid,
|
||||||
'width' => 1920,
|
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
|
||||||
'height' => 1080,
|
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
|
||||||
'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
|
private function buildArrangements(Song $song): array
|
||||||
{
|
{
|
||||||
$arrangements = [];
|
$arrangements = [];
|
||||||
|
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
|
||||||
|
|
||||||
foreach ($song->arrangements as $arrangement) {
|
foreach ($song->arrangements as $arrangement) {
|
||||||
$arrangement->loadMissing('arrangementSections.section.label');
|
$groupNames = $arrangement->arrangementGroups
|
||||||
|
|
||||||
$groupNames = $arrangement->arrangementSections
|
|
||||||
->sortBy('order')
|
->sortBy('order')
|
||||||
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null)
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,10 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Label;
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\SongArrangement;
|
use App\Models\SongArrangement;
|
||||||
use App\Models\SongArrangementLabel;
|
use App\Models\SongArrangementGroup;
|
||||||
use App\Models\SongSection;
|
use App\Models\SongGroup;
|
||||||
use App\Support\MacroColorConverter;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use ProPresenter\Parser\ProFileReader;
|
use ProPresenter\Parser\ProFileReader;
|
||||||
|
|
@ -105,35 +103,28 @@ private function upsertSong(ProSong $proSong): Song
|
||||||
}
|
}
|
||||||
|
|
||||||
$song->arrangements()->each(function (SongArrangement $arr) {
|
$song->arrangements()->each(function (SongArrangement $arr) {
|
||||||
$arr->arrangementSections()->delete();
|
$arr->arrangementGroups()->delete();
|
||||||
});
|
});
|
||||||
$song->arrangements()->delete();
|
$song->arrangements()->delete();
|
||||||
|
$song->groups()->each(function (SongGroup $group) {
|
||||||
|
$group->slides()->delete();
|
||||||
|
});
|
||||||
|
$song->groups()->delete();
|
||||||
|
|
||||||
$hasTranslation = false;
|
$hasTranslation = false;
|
||||||
$sectionsByName = [];
|
$groupMap = [];
|
||||||
|
|
||||||
foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
|
foreach ($proSong->getGroups() as $position => $proGroup) {
|
||||||
$groupName = $proGroup->getName();
|
$color = $proGroup->getColor();
|
||||||
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
|
$hexColor = $color ? self::rgbaToHex($color) : '#808080';
|
||||||
|
|
||||||
if ($existingLabel === null) {
|
$songGroup = $song->groups()->create([
|
||||||
$color = $proGroup->getColor();
|
'name' => $proGroup->getName(),
|
||||||
$hexColor = MacroColorConverter::fromRgba($color);
|
'color' => $hexColor,
|
||||||
|
'order' => $position,
|
||||||
|
]);
|
||||||
|
|
||||||
$existingLabel = Label::create([
|
$groupMap[$proGroup->getName()] = $songGroup;
|
||||||
'name' => $groupName,
|
|
||||||
'color' => $hexColor,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$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) {
|
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
|
||||||
$translatedText = null;
|
$translatedText = null;
|
||||||
|
|
@ -143,7 +134,7 @@ private function upsertSong(ProSong $proSong): Song
|
||||||
$hasTranslation = true;
|
$hasTranslation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$section->slides()->create([
|
$songGroup->slides()->create([
|
||||||
'order' => $slidePosition,
|
'order' => $slidePosition,
|
||||||
'text_content' => $proSlide->getPlainText(),
|
'text_content' => $proSlide->getPlainText(),
|
||||||
'text_content_translated' => $translatedText,
|
'text_content_translated' => $translatedText,
|
||||||
|
|
@ -162,19 +153,19 @@ private function upsertSong(ProSong $proSong): Song
|
||||||
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
|
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
|
||||||
|
|
||||||
foreach ($groupsInArrangement as $order => $proGroup) {
|
foreach ($groupsInArrangement as $order => $proGroup) {
|
||||||
$section = $sectionsByName[$proGroup->getName()] ?? null;
|
$songGroup = $groupMap[$proGroup->getName()] ?? null;
|
||||||
|
|
||||||
if ($section) {
|
if ($songGroup) {
|
||||||
SongArrangementLabel::create([
|
SongArrangementGroup::create([
|
||||||
'song_arrangement_id' => $arrangement->id,
|
'song_arrangement_id' => $arrangement->id,
|
||||||
'song_section_id' => $section->id,
|
'song_group_id' => $songGroup->id,
|
||||||
'order' => $order,
|
'order' => $order,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function rgbaToHex(array $rgba): string
|
public static function rgbaToHex(array $rgba): string
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,55 +2,36 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Label;
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\SongArrangement;
|
use App\Models\SongArrangement;
|
||||||
use App\Models\SongArrangementLabel;
|
use App\Models\SongArrangementGroup;
|
||||||
use App\Models\SongSection;
|
use App\Models\SongGroup;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class SongService
|
class SongService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Sicherstellen, dass die Default-Labels und Song-Sektionen existieren.
|
* Default-Gruppen für ein neues Lied erstellen.
|
||||||
*
|
*
|
||||||
* @return Collection<int, SongSection>
|
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
|
||||||
*/
|
*/
|
||||||
public function createDefaultGroups(Song $song): Collection
|
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
['name' => 'Strophe 1', 'color' => '#3B82F6'],
|
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
|
||||||
['name' => 'Refrain', 'color' => '#10B981'],
|
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
|
||||||
['name' => 'Bridge', 'color' => '#F59E0B'],
|
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
|
||||||
];
|
];
|
||||||
|
|
||||||
$sections = collect();
|
foreach ($defaults as $groupData) {
|
||||||
|
$song->groups()->create($groupData);
|
||||||
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'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$section = SongSection::firstOrCreate(
|
|
||||||
['song_id' => $song->id, 'label_id' => $existing->id],
|
|
||||||
['order' => $index + 1],
|
|
||||||
);
|
|
||||||
$section->update(['order' => $index + 1]);
|
|
||||||
|
|
||||||
$sections->push($section);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $sections;
|
return $song->groups()->orderBy('order')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
|
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
|
||||||
*/
|
*/
|
||||||
public function createDefaultArrangement(Song $song): SongArrangement
|
public function createDefaultArrangement(Song $song): SongArrangement
|
||||||
{
|
{
|
||||||
|
|
@ -59,16 +40,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sections = $this->createDefaultGroups($song);
|
$groups = $song->groups()->orderBy('order')->get();
|
||||||
|
|
||||||
foreach ($sections->values() as $index => $section) {
|
foreach ($groups as $index => $group) {
|
||||||
$arrangement->arrangementSections()->create([
|
$arrangement->arrangementGroups()->create([
|
||||||
'song_section_id' => $section->id,
|
'song_group_id' => $group->id,
|
||||||
'order' => $index + 1,
|
'order' => $index + 1,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $arrangement->load('arrangementSections.section.label');
|
return $arrangement->load('arrangementGroups');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -82,15 +63,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
|
||||||
$clone->is_default = false;
|
$clone->is_default = false;
|
||||||
$clone->save();
|
$clone->save();
|
||||||
|
|
||||||
foreach ($arrangement->arrangementSections()->orderBy('order')->get() as $arrangementSection) {
|
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
|
||||||
SongArrangementLabel::create([
|
SongArrangementGroup::create([
|
||||||
'song_arrangement_id' => $clone->id,
|
'song_arrangement_id' => $clone->id,
|
||||||
'song_section_id' => $arrangementSection->song_section_id,
|
'song_group_id' => $group->song_group_id,
|
||||||
'order' => $arrangementSection->order,
|
'order' => $group->order,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $clone->load('arrangementSections.section.label');
|
return $clone->load('arrangementGroups');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@
|
||||||
|
|
||||||
class TranslationService
|
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
|
public function fetchFromUrl(string $url): ?string
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,30 +33,29 @@ public function fetchFromUrl(string $url): ?string
|
||||||
return null;
|
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
|
public function importTranslation(Song $song, string $text): void
|
||||||
{
|
{
|
||||||
$translatedLines = explode("\n", $text);
|
$translatedLines = explode("\n", $text);
|
||||||
$offset = 0;
|
$offset = 0;
|
||||||
|
|
||||||
$defaultArr = $song->arrangements()
|
// Alle Gruppen nach order sortiert laden, mit Slides
|
||||||
->where('is_default', true)
|
$groups = $song->groups()->orderBy('order')->with([
|
||||||
->with(['arrangementSections' => fn ($q) => $q->orderBy('order'), 'arrangementSections.section.slides'])
|
'slides' => fn ($query) => $query->orderBy('order'),
|
||||||
->first();
|
])->get();
|
||||||
|
|
||||||
if ($defaultArr === null) {
|
foreach ($groups as $group) {
|
||||||
$this->markAsTranslated($song);
|
foreach ($group->slides as $slide) {
|
||||||
|
|
||||||
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 ?? ''));
|
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
|
||||||
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
||||||
$offset += $originalLineCount;
|
$offset += $originalLineCount;
|
||||||
|
|
@ -64,23 +69,30 @@ public function importTranslation(Song $song, string $text): void
|
||||||
$this->markAsTranslated($song);
|
$this->markAsTranslated($song);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Song als "hat Übersetzung" markieren.
|
||||||
|
*/
|
||||||
public function markAsTranslated(Song $song): void
|
public function markAsTranslated(Song $song): void
|
||||||
{
|
{
|
||||||
$song->update(['has_translation' => true]);
|
$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
|
public function removeTranslation(Song $song): void
|
||||||
{
|
{
|
||||||
$sectionIds = $song->sections()
|
// Alle Slides des Songs über die Gruppen aktualisieren
|
||||||
->pluck('id')
|
$slideIds = SongSlide::whereIn(
|
||||||
->unique()
|
'song_group_id',
|
||||||
->values();
|
$song->groups()->pluck('id')
|
||||||
|
)->pluck('id');
|
||||||
|
|
||||||
if ($sectionIds->isNotEmpty()) {
|
SongSlide::whereIn('id', $slideIds)->update([
|
||||||
SongSlide::whereIn('song_section_id', $sectionIds)->update([
|
'text_content_translated' => null,
|
||||||
'text_content_translated' => null,
|
]);
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$song->update(['has_translation' => false]);
|
$song->update(['has_translation' => false]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -52,10 +52,6 @@ RUN composer run-script post-autoload-dump --no-interaction || true
|
||||||
|
|
||||||
RUN npm run build
|
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
|
# Stage 2: Production
|
||||||
|
|
@ -78,6 +74,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
unzip \
|
unzip \
|
||||||
zip \
|
zip \
|
||||||
curl \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# LibreOffice for PowerPoint → PDF conversion (large layer, separate cache)
|
# LibreOffice for PowerPoint → PDF conversion (large layer, separate cache)
|
||||||
|
|
@ -131,7 +133,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q "pong" || exit 1
|
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,
|
# boot-container.sh runs as root: creates dirs, sets permissions,
|
||||||
# creates DB on first run, syncs pre-built Vite assets from /app/public-build/,
|
# creates DB on first run, builds Vite assets, runs migrations,
|
||||||
# runs migrations, warms caches, then exec's supervisord (CMD).
|
# warms caches, then exec's supervisord (CMD).
|
||||||
ENTRYPOINT ["/app/build/boot-container.sh"]
|
ENTRYPOINT ["/app/build/boot-container.sh"]
|
||||||
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ chmod -R 775 storage bootstrap/cache database 2>/dev/null || true
|
||||||
|
|
||||||
rm -f /app/public/hot
|
rm -f /app/public/hot
|
||||||
|
|
||||||
echo "[boot] Syncing pre-built Vite assets to bind-mounted public/ ..."
|
echo "[boot] Building Vite assets..."
|
||||||
cp -r /app/public-build/* /app/public/ 2>/dev/null || true
|
npm run build
|
||||||
|
|
||||||
# Create RELATIVE storage symlink (public/storage → ../storage/app/public).
|
# Create RELATIVE storage symlink (public/storage → ../storage/app/public).
|
||||||
# Must be relative: Caddy serves the bind-mounted ./public from the host, where
|
# Must be relative: Caddy serves the bind-mounted ./public from the host, where
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ fi
|
||||||
echo "[init] First run detected — initializing application..."
|
echo "[init] First run detected — initializing application..."
|
||||||
|
|
||||||
touch "$DB_PATH"
|
touch "$DB_PATH"
|
||||||
chown www-data:www-data "$DB_PATH"
|
|
||||||
chmod 664 "$DB_PATH"
|
chmod 664 "$DB_PATH"
|
||||||
|
|
||||||
if [ -z "${APP_KEY}" ]; then
|
if [ -z "${APP_KEY}" ]; then
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.2",
|
||||||
"5pm-hdh/churchtools-api": "^2.1",
|
"5pm-hdh/churchtools-api": "^2.1",
|
||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"laravel/breeze": "^2.3",
|
"laravel/breeze": "^2.3",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"pestphp/pest": "^4.4",
|
"pestphp/pest": "^4.4",
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue