Compare commits
25 commits
dev-with-v
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a10068e783 | ||
|
|
eee35722fb | ||
|
|
84adf2b6fb | ||
|
|
41d4bfe2b7 | ||
|
|
6d83b5f38c | ||
|
|
444e6704c5 | ||
|
|
c714f30647 | ||
|
|
b88ae3e918 | ||
|
|
f494a8a0d7 | ||
|
|
c1cb9bf820 | ||
|
|
6ce5b6e018 | ||
|
|
cef247336e | ||
|
|
81b2a9caf6 | ||
|
|
bdbf0c65e3 | ||
|
|
a1612dc3ef | ||
|
|
846bd12f90 | ||
|
|
2a02f65517 | ||
|
|
bf153b2906 | ||
|
|
2b27aa50d5 | ||
|
|
a65bf3d595 | ||
|
|
09ab4821fc | ||
|
|
860db0405f | ||
|
|
767e22eac8 | ||
|
|
e489a984eb | ||
|
|
599b8635c9 |
17
.ddev/commands/web/dev
Executable file
17
.ddev/commands/web/dev
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
## Description: Run Laravel dev workers (queue, log tail, vite) inside the web container.
|
||||
## Usage: dev
|
||||
## Example: ddev dev
|
||||
## ProjectTypes: laravel
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd /var/www/html
|
||||
|
||||
exec npx --yes concurrently \
|
||||
-c "#c4b5fd,#fb7185,#fdba74" \
|
||||
--names=queue,logs,vite \
|
||||
--kill-others \
|
||||
"php artisan queue:listen --tries=1 --timeout=0" \
|
||||
"php artisan pail --timeout=0" \
|
||||
"npm run dev"
|
||||
312
.ddev/config.yaml
Normal file
312
.ddev/config.yaml
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
name: pp-planer
|
||||
type: laravel
|
||||
docroot: public
|
||||
php_version: "8.4"
|
||||
webserver_type: nginx-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames: []
|
||||
additional_fqdns: []
|
||||
database:
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
hooks:
|
||||
post-start:
|
||||
- composer: install --no-interaction
|
||||
- exec: test -f .env || cp .env.example .env
|
||||
- exec: grep -q '^APP_KEY=base64:' .env || php artisan key:generate
|
||||
- exec: test -f database/database.sqlite || (touch database/database.sqlite && chmod 664 database/database.sqlite)
|
||||
- exec: php artisan migrate --force
|
||||
# Drop node_modules+lock if they were installed for a different platform
|
||||
# (e.g. host was macOS but container is Linux — rollup native binary mismatch).
|
||||
- exec: '[ ! -d node_modules ] || node -e ''require("rollup/dist/native")'' >/dev/null 2>&1 || rm -rf node_modules package-lock.json'
|
||||
- exec: test -d node_modules || npm install
|
||||
- exec: npm run build
|
||||
omit_containers: [db]
|
||||
webimage_extra_packages: [libreoffice, libreoffice-l10n-de, ghostscript, poppler-utils, sqlite3]
|
||||
use_dns_when_possible: true
|
||||
composer_version: "2"
|
||||
disable_settings_management: true
|
||||
web_environment: []
|
||||
nodejs_version: "20"
|
||||
corepack_enable: false
|
||||
web_extra_exposed_ports:
|
||||
- name: vite
|
||||
container_port: 5173
|
||||
http_port: 5172
|
||||
https_port: 5173
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
# name: <projectname> # Name of the project, automatically provides
|
||||
# http://projectname.ddev.site and https://projectname.ddev.site
|
||||
# If the name is omitted, the project will take the name of the enclosing directory,
|
||||
# which is useful if you want to have a copy of the project side by side with this one.
|
||||
|
||||
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
|
||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||
# information on the different project types
|
||||
|
||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||
|
||||
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||
|
||||
# You can explicitly specify the webimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# webimage: <docker_image>
|
||||
# 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_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://cts-work.test
|
||||
APP_URL=https://pp-planer.ddev.site
|
||||
|
||||
# Application Locale (German)
|
||||
APP_LOCALE=de
|
||||
|
|
@ -74,7 +74,7 @@ CTS_API_TOKEN=CHANGEME
|
|||
CHURCHTOOLS_URL=https://CHANGEME.church.tools
|
||||
CHURCHTOOLS_CLIENT_ID=CHANGEME
|
||||
CHURCHTOOLS_CLIENT_SECRET=CHANGEME
|
||||
CHURCHTOOLS_REDIRECT_URI=http://cts-work.test/auth/churchtools/callback
|
||||
CHURCHTOOLS_REDIRECT_URI=https://pp-planer.ddev.site/auth/churchtools/callback
|
||||
|
||||
# File Upload Configuration
|
||||
# Maximum file size in bytes (default: 100MB)
|
||||
|
|
@ -85,6 +85,7 @@ UPLOAD_TEMP_DIR=/tmp
|
|||
TEST_CTS_USERNAME=
|
||||
TEST_CTS_PASSWORD=
|
||||
|
||||
# Docker: map FPM worker to host user (run `id -u` and `id -g`)
|
||||
# Production Docker only: map FPM worker to host user (run `id -u` and `id -g`).
|
||||
# Not used by DDEV local dev.
|
||||
WWWUSER=1000
|
||||
WWWGROUP=1000
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,3 +27,4 @@ Thumbs.db
|
|||
tests/e2e/.auth/
|
||||
test-results/
|
||||
.dev.pid
|
||||
.dev.log
|
||||
|
|
|
|||
9
.sisyphus/evidence/task-1.1-pest.txt
Normal file
9
.sisyphus/evidence/task-1.1-pest.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
php artisan test tests/Feature/Migrations/LabelsTableTest.php
|
||||
|
||||
PASS Tests\Feature\Migrations\LabelsTableTest
|
||||
✓ labels table has expected columns 0.40s
|
||||
✓ labels table enforces unique name 0.01s
|
||||
✓ labels table allows nullable color 0.01s
|
||||
|
||||
Tests: 3 passed (4 assertions)
|
||||
Duration: 0.54s
|
||||
33
.sisyphus/evidence/task-1.1-schema.txt
Normal file
33
.sisyphus/evidence/task-1.1-schema.txt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
migrate:fresh output
|
||||
|
||||
Dropping all tables ........................................... 13.01ms DONE
|
||||
|
||||
INFO Preparing database.
|
||||
|
||||
Creating migration table ....................................... 4.76ms DONE
|
||||
|
||||
INFO Running migrations.
|
||||
|
||||
0001_01_01_000000_create_users_table ........................... 9.49ms DONE
|
||||
0001_01_01_000001_create_cache_table ........................... 4.35ms DONE
|
||||
0001_01_01_000002_create_jobs_table ............................ 4.88ms DONE
|
||||
2026_03_01_100000_extend_users_table ........................... 4.60ms DONE
|
||||
2026_03_01_100100_create_services_table ........................ 2.92ms DONE
|
||||
2026_03_01_100200_create_songs_table ........................... 2.08ms DONE
|
||||
2026_03_01_100300_create_song_groups_table ..................... 3.09ms DONE
|
||||
2026_03_01_100400_create_song_slides_table ..................... 5.10ms DONE
|
||||
2026_03_01_100500_create_song_arrangements_table ............... 3.19ms DONE
|
||||
2026_03_01_100600_create_song_arrangement_groups_table ......... 3.61ms DONE
|
||||
2026_03_01_100700_create_service_songs_table ................... 3.25ms DONE
|
||||
2026_03_01_100800_create_slides_table .......................... 3.68ms DONE
|
||||
2026_03_01_100900_create_cts_sync_log_table .................... 4.32ms DONE
|
||||
2026_03_02_100000_create_api_request_logs_table ................ 2.15ms DONE
|
||||
2026_03_02_121522_add_response_body_to_api_request_logs_table .. 1.31ms DONE
|
||||
2026_03_02_130249_add_cts_song_id_to_songs_and_service_songs_tables 3.30ms DONE
|
||||
2026_03_02_140000_add_sort_order_to_slides_table ............... 0.91ms DONE
|
||||
2026_03_02_200000_create_settings_table ........................ 2.48ms DONE
|
||||
2026_03_29_100001_create_service_agenda_items_table ............ 3.03ms DONE
|
||||
2026_03_29_100002_add_service_agenda_item_id_to_slides_table .. 13.03ms DONE
|
||||
2026_03_29_131045_add_missing_cts_song_id_to_service_songs_table 0.53ms DONE
|
||||
2026_03_29_131359_add_has_agenda_to_services_table ............. 1.24ms DONE
|
||||
2026_05_03_100100_create_labels_table .......................... 2.50ms DONE
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
|
||||
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
|
||||
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
|
||||
- 2026-05-03: `MacroColorConverter::fromRgba()` muss nur RGB clampen und als uppercase-6-digit Hex ausgeben; `tinker --execute` ist eine schnelle Verifikation fuer solche statischen Helper.
|
||||
|
||||
## [2026-03-01] Wave 2 Complete — T8-T13
|
||||
|
||||
|
|
@ -350,3 +351,5 @@ ### Verification Success Criteria Met
|
|||
### Next Steps
|
||||
- Task 2 will likely involve testing OAuth login flow with ChurchTools
|
||||
- May need to configure CTS_API_TOKEN and CHURCHTOOLS credentials for full testing
|
||||
|
||||
- 2026-05-04: `ProBundleExportService` muss fuer non-song `.probundle` Exporte den aktuellen `Service` plus `part_type` bis in `buildBundleFromSlides()` durchreichen; `MacroResolutionService::macrosForSlide()` bekommt fuer Bildfolien `label_id => null`, damit nur all/first/last Positionen greifen.
|
||||
|
|
|
|||
41
.sisyphus/notepads/macros-and-labels-import/decisions.md
Normal file
41
.sisyphus/notepads/macros-and-labels-import/decisions.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Decisions — macros-and-labels-import
|
||||
|
||||
## [2026-05-03] Architectural Decisions
|
||||
|
||||
### Schema
|
||||
- **labels table**: global, unique by name, nullable color, hidden_at (NOT deleted_at)
|
||||
- **macros table**: unique by uuid (uppercase), hidden_at (NOT deleted_at)
|
||||
- **macro_assignments**: restrictOnDelete on macro_id and label_id FKs
|
||||
- **service_macro_overrides**: existence of row = override active; no extra boolean
|
||||
- **song_arrangement_labels**: replaces song_arrangement_groups; references global label_id
|
||||
|
||||
### Macro Assignment Semantics
|
||||
- `part_type` enum: `information | moderation | sermon | song | agenda_item`
|
||||
- `position` enum: `all_slides | first_slide | last_slide | by_label`
|
||||
- `by_label` is valid for ALL part_types (not songs-only) — validated at app level if restriction needed
|
||||
- Stacking: multiple assignments can fire on same slide — all applied in `order ASC`
|
||||
- Override wins 100% — no globals bleed through when override exists
|
||||
|
||||
### Override Semantics
|
||||
- "Anpassen" snapshots current globals into `service_macro_assignments` rows
|
||||
- "Auf Standard zurücksetzen" deletes the override row + cascades service_macro_assignments
|
||||
- German tooltip: "Erstellt eine Kopie der aktuellen globalen Zuweisungen für diesen Gottesdienst. Spätere Änderungen an den globalen Zuweisungen wirken sich auf diesen Gottesdienst NICHT mehr aus."
|
||||
|
||||
### Data Migration
|
||||
- Destructive: `up()` deletes songs, song_groups, song_slides, song_arrangements, song_arrangement_groups
|
||||
- `down()` throws RuntimeException (irreversible)
|
||||
- Guard: `if (!Schema::hasTable('song_groups') || !DB::table('song_groups')->exists()) return;`
|
||||
- Old 4 macro settings keys → migrated to global assignment if all present; then deleted
|
||||
|
||||
### Label Color Priority
|
||||
1. Labels file import → always sets/overwrites color
|
||||
2. .pro song import → only sets color on CREATE (new label); existing color preserved
|
||||
3. UI → read-only (no manual edit)
|
||||
|
||||
### Current Migration Scope
|
||||
- `labels` migration only defines the schema; no model or business logic belongs in this task
|
||||
- Use `hidden_at` instead of `deleted_at` to align with soft-hide semantics
|
||||
|
||||
### Macros Tables Task
|
||||
- Keep all three tables in one migration file so the schema lands together and the junction FKs resolve cleanly during `migrate:fresh`
|
||||
- Store `last_imported_filename` as nullable text metadata on `macros`; no separate import log table for this task
|
||||
145
.sisyphus/notepads/macros-and-labels-import/learnings.md
Normal file
145
.sisyphus/notepads/macros-and-labels-import/learnings.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Learnings — macros-and-labels-import
|
||||
|
||||
## [2026-05-03] Session ses_210cd1557ffeGs4SEGrt7hnvyS — Plan Created
|
||||
|
||||
### Parser Library
|
||||
- Source at `/Users/thorsten/AI/propresenter/src/` (NOT `/Users/thorsten/AI/propresenter-work/php/` per stale AGENTS.md)
|
||||
- VCS repo: `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (dev-master)
|
||||
- New classes (NOT yet in vendor/): `MacrosFileReader`, `LabelsFileReader`, `Macro`, `MacroLibrary`, `MacroCollection`, `Label`, `LabelLibrary`
|
||||
- `MacrosFileReader::read(string $filePath): MacroLibrary` — raw protobuf binary, no extension
|
||||
- `LabelsFileReader::read(string $filePath): LabelLibrary` — same
|
||||
- `Label::getName()` returns protobuf `text` field — name is the identity (no UUID for labels)
|
||||
- `Macro::getColor()` returns `?array{r,g,b,a}` floats 0..1 — need `MacroColorConverter` to get hex
|
||||
- `Label::getColorHex()` already returns `#RRGGBB` — mirror its formula for macros
|
||||
- **PHP 8.4 required** by parser. App currently requires `^8.2` — BLOCKER for T0.1
|
||||
|
||||
### DB Schema Key Facts
|
||||
- `slides.type` enum is `[information, moderation, sermon]` ONLY — no `agenda_item`
|
||||
- `agenda_item` part_type = slide where `service_agenda_item_id IS NOT NULL` at runtime
|
||||
- `song_groups.color` is NOT NULLABLE (migration says so) — new `labels.color` IS nullable
|
||||
- `service_songs.song_id` is `cascadeOnDelete` — wiping `songs` auto-cascades to `service_songs`
|
||||
|
||||
### Export Flow
|
||||
- `ProExportService::buildGroups()` lines 38-69 — macro injection point
|
||||
- `ProExportService::buildMacroData()` lines 71-86 — reads 4 legacy settings keys
|
||||
- Currently injects macro ONLY when group name is "COPYRIGHT" (case-insensitive)
|
||||
- `ProImportService::import(UploadedFile $file): array` — method signature (NOT `importFromFile`)
|
||||
|
||||
### Settings Pattern
|
||||
- `Setting::get($key, $default)` / `Setting::set($key, $value)` — simple key/value
|
||||
- `settings` table: `key UNIQUE, value TEXT`
|
||||
|
||||
### Critical Decisions
|
||||
- song_groups → labels: global table, "drop all data" migration (no backwards compat)
|
||||
- Hybrid macro scope: global defaults in Settings; per-(service, part_type) override via "Anpassen"
|
||||
- Override = snapshot of globals at creation time; future global changes don't propagate
|
||||
- Stacking: all matching assignments fire, ordered by `macro_assignments.order ASC`
|
||||
- Hidden macros/labels: skip at export, warning badge in editor
|
||||
- Label colors: read-only in UI; Labels file import is sole authority; .pro auto-discovery only sets color on CREATE
|
||||
- FK rules: `restrictOnDelete` on macro/label refs (use `hidden_at`); `cascadeOnDelete` on service-scoped rows
|
||||
|
||||
### Migration/Test Notes
|
||||
- `tests/Pest.php` already applies `RefreshDatabase` to all `Feature` tests; no extra setup needed for `Feature/Migrations`
|
||||
- SQLite unique constraint errors can be asserted with `->toThrow(\Exception::class)` in migration tests
|
||||
- `macro_collection_macros` can safely use a reserved-ish `order` column name in SQLite/Laravel migrations; the schema + foreign keys passed `migrate:fresh`
|
||||
- `foreignId()->constrained()->cascadeOnDelete()` correctly cascades through the junction table under the current sqlite test setup
|
||||
|
||||
## T2.1 — Models + Factories (label-based schema)
|
||||
|
||||
- **All new model conventions match house style**: `$fillable` array, `casts()` method (not `$casts` property), typed return types on relations.
|
||||
- **`hidden_at` semantics, NOT SoftDeletes**: `Label` and `Macro` use `hidden_at` timestamp + `isHidden()` helper; SoftDeletes deliberately not used.
|
||||
- **`MacroCollection` pivot ordering**: `belongsToMany(Macro::class, 'macro_collection_macros')->withPivot('order')->orderBy('macro_collection_macros.order')` — must qualify the column with the pivot table name to avoid SQLite ambiguous column errors.
|
||||
- **`ServiceMacroOverride::assignments()` uses composite-key relation**: HasMany on `service_id` with explicit `where('part_type', $this->part_type)` filter (Eloquent has no native composite-FK support).
|
||||
- **`SongArrangement::arrangementLabels()` ordered**: `hasMany(SongArrangementLabel::class)->orderBy('order')` so consumers see labels in the correct slide order without re-sorting.
|
||||
- **`SongArrangementLabelFactory`** uses `Label::factory()` and `SongArrangement::factory()` directly — both have HasFactory trait.
|
||||
- **Test gating**: After T2.1 alone, 270/328 tests pass. The remaining 58 failures are all in `app/Services/SongService.php`, `app/Services/ProImportService.php`, and the test files that exercise those services; T4.4 owns those updates.
|
||||
- **`DatabaseSchemaTest`** passes cleanly (3 tests / 31 assertions): all expected tables exist, dropped tables gone, all factories produce valid rows.
|
||||
|
||||
## T4.4 — PHP rename audit (2026-05-03)
|
||||
|
||||
After Wave 2's schema migration (`song_groups` → `labels`, `song_arrangement_groups` → `song_arrangement_labels`), the rename-audit cleanup turned out to span **far more files** than the plan listed (12 app files + 11 test files vs 7 listed). Key findings:
|
||||
|
||||
- `Song::groups()` relation was completely removed; many call sites needed adaptation, not just rename. New pattern: traverse `Song -> arrangements -> arrangementLabels -> label -> songSlides` for content.
|
||||
- `song_slides` table only has `label_id` (no `song_id` either) — slides are now globally owned by labels. Tests that previously did `$verse = $song->groups()->create(...)` need to find/create a global Label and link it via `SongArrangementLabel`.
|
||||
- Helper functions defined at file level in Pest tests work cleanly: `function makeSongWithDefaultArrangement(): array { ... }` keeps test setup DRY.
|
||||
- Fixture `Test.pro` has 4 groups but only 3 are referenced in any arrangement — assertion needs to count `Label::count()` (post-import) to verify "all 4 groups created", not arrangement labels.
|
||||
- `MacroColorConverter::fromRgba()` (assoc-keyed `r,g,b`) replaces the old `ProImportService::rgbaToHex()` for label color conversion in importer; the legacy hex helpers were preserved because `ProFileGenerator::colorFromArray` uses numeric-indexed RGBA.
|
||||
- Removing the "groups must belong to this song" check in `ArrangementController::update` is correct since labels are global; `exists:labels,id` validation is sufficient.
|
||||
|
||||
## Wave 2 — T2.3, T2.4, T2.5 (services)
|
||||
|
||||
### LabelsImportService
|
||||
- Case-insensitive name lookup via `whereRaw('LOWER(name) = ?', [strtolower($name)])`
|
||||
- Always updates color on existing labels (additive policy, never disables)
|
||||
- Skips labels with empty names
|
||||
- Stores metadata in `settings` table: `labels_last_imported_at`, `labels_last_imported_filename`
|
||||
|
||||
### MacrosImportService
|
||||
- UUID is normalized to UPPER before storage (matches parser convention)
|
||||
- Macros not in file get `hidden_at = now()` (soft-disable, not delete)
|
||||
- Re-import re-enables a previously hidden macro by setting `hidden_at = null`
|
||||
- Tracks `wasHidden` to differentiate `reEnabled` vs `updated` counts
|
||||
- Collection sync: detach all → attach with order index from parser
|
||||
- Warnings: any MacroAssignment whose macro is currently hidden
|
||||
|
||||
### MacroResolutionService
|
||||
- Override-vs-defaults: `ServiceMacroOverride` existence check decides whether to use service-specific or global assignments
|
||||
- Hidden macros and hidden labels (for `by_label`) are filtered via Collection->reject()
|
||||
- `macrosForSlide` uses match() expression for position semantics
|
||||
- Default collection fallback: `--MAIN--` with UUID `8D02FC57-83F8-4042-9B90-81C229728426`
|
||||
|
||||
### Pint quirk
|
||||
- DTO classes with empty body need `{}` on same line as constructor closing paren — `single_line_empty_body` rule.
|
||||
|
||||
### Test patterns
|
||||
- Pest auto-applies `RefreshDatabase` via `tests/Pest.php` for all Feature tests, but explicit `uses(RefreshDatabase::class)` is harmless and matches spec.
|
||||
- All 354 tests pass (was 334 before Wave 2.3-2.5).
|
||||
|
||||
## T2.7 ProExportService MacroResolutionService
|
||||
- ProPresenter parser package currently consumes only `$slideData['macro']` in `ProFileGenerator::buildCue()`; no `$slideData['macros']` stacking support exists. `Slide::setMacro()` also updates/replaces the first macro action.
|
||||
- `ProExportService` now keeps song downloads backward-compatible by accepting optional `?Service`; exports without service context intentionally emit no macros.
|
||||
- Playlist/bundle service exports must pass the active `Service` into `generateProFile()` / `generateParserSong()` so `MacroResolutionService::macrosForSlide()` can resolve global or service-specific assignments.
|
||||
- Full verification for T2.7: `ddev exec php artisan test` passed with 357 tests / 1706 assertions; evidence in `.sisyphus/evidence/task-2.7-pest.txt`.
|
||||
|
||||
## T2.8 Controllers + Routes (2026-05-03)
|
||||
|
||||
- **4 thin controllers, all JSON responses for mutations** (Inertia only on `MacroAssignmentController::index`).
|
||||
- **Validation via inline `$request->validate()`** with `in:` lists for `part_type` (information, moderation, sermon, song, agenda_item) and `position` (all_slides, first_slide, last_slide, by_label).
|
||||
- **Route ordering matters**: `/settings/macro-assignments/reorder` MUST be registered BEFORE `/settings/macro-assignments/{macroAssignment}`, else `reorder` is captured as the model parameter.
|
||||
- **Route-model binding works automatically** for both `{macroAssignment}` and `{serviceMacroAssignment}` — Laravel resolves snake_case → StudlyCase → Eloquent model.
|
||||
- **Unused `$service` parameter on update/destroyAssignment** is intentional: route-model binding requires it in the signature even if the assignment binding alone does the work.
|
||||
- **Generic 422 message** for parser failures hides internal exception details from users; all messages German Du-form.
|
||||
- **Test fixtures `tests/fixtures/macros-sample.bin` & `labels-sample.bin`** work with `new UploadedFile(path, name, null, null, true)` (5th arg `$test=true` keeps the file at original path so `getPathname()` returns the fixture).
|
||||
- **`UploadedFile::fake()->create('x.bin', 1)`** generates a 1KB empty file that fails parser parsing → triggers the controller's catch block → 422 JSON.
|
||||
- **Auth tests use plain `post()` (form-data) → `assertRedirect(route('login'))`**; JSON requests would return 401, but session-based auth redirects.
|
||||
- **Final test count: 376 (was 357) → +19 new tests / +54 assertions.**
|
||||
|
||||
## T4.2: Service Edit Macro Panel
|
||||
|
||||
- `ServiceController::edit()` now passes `macros_per_part` keyed by part_type (information, moderation, sermon, song, agenda_item).
|
||||
- Each entry: `count`, `is_overridden`, `has_warning`, `assignments[]` (with macro_id/name/color/hidden, position, label_id/name).
|
||||
- Uses `MacroResolutionService::resolveAssignmentsForPart()` (already filters hidden macros + by_label with hidden labels). `has_warning` checks raw flags before resolver filters them — but since resolver already filters, `has_warning` will normally be false. Acceptable for badge UI.
|
||||
- `ServiceMacroOverride::where(...)->exists()` checks override status per part.
|
||||
- `ServicePartMacroPanel.vue` is positioned `absolute right-0 top-8 z-50` — wrapper must be `class="relative"`.
|
||||
- Edit.vue page only has 2 visible block headers (Ablauf and Information). Placed agenda_item/moderation/sermon/song MacroIcons in the Ablauf header row; placed information MacroIcon in the Information block header.
|
||||
- MacroIcon renders only when `count > 0`, so empty parts gracefully hide their badge.
|
||||
- Routes used: `services.macro-overrides.store` (POST + body `{part_type}`), `services.macro-overrides.destroy` (DELETE + body). XSRF token sourced from `XSRF-TOKEN` cookie (URL-decoded).
|
||||
|
||||
## Final Verification F4 (2026-05-04)
|
||||
|
||||
- Scope-fidelity verification passed: `Label`/`Macro` use `hidden_at` (no SoftDeletes), label imports are additive with color overwrite, missing macros are hidden via `hidden_at`, `MacroResolutionService` resolves override/default assignments and filters hidden macros/labels, `ProExportService` injects `MacroResolutionService` with no legacy `buildMacroData()`, and `SettingsController` only exposes the four `AGENDA_KEYS`.
|
||||
- Forbidden-pattern grep suite returned no output for label CRUD, macro action runner/editor patterns, TS suppressions, Vue console logs, bulk operations, label/macro drag reorder, and export caching.
|
||||
|
||||
## [2026-05-04] Session follow-up — hidden label badge + nullable import color
|
||||
|
||||
- `MacroAssignments.vue` should mirror hidden-macro warnings for `by_label` rows with `a.label?.hidden_at`, using a red badge and `data-testid="warning-hidden-label"`.
|
||||
- `ProImportService` must keep new label colors nullable: `MacroColorConverter::fromRgba($color)` should flow through unchanged so missing `.pro` colors become `NULL`, not `#808080`.
|
||||
|
||||
## 2026-05-04 F1 final compliance audit
|
||||
- Final verification commands passed: no Vue song_group_id references, MacroIcon and hidden-label test IDs present, ProImportService #808080 fallback removed, required macro/label deliverables and schema present, Label/Macro use hidden_at without SoftDeletes, routes present, ProBundleExportService resolves macros for information/moderation/sermon and agenda_item exports.
|
||||
- MacroResolutionService supports part types dynamically via part_type string; grep for literal part names can be empty without indicating non-support.
|
||||
|
||||
## 2026-05-04 F4 scope fidelity check
|
||||
- Must-NOT grep suite found one historical toast pattern in pre-existing service/song Vue files; no macro/label feature-specific forbidden patterns were found (no SoftDeletes/deleted_at, drag UI, runner/preview, bulk ops, optimistic markers, collection assignment, export caching, agenda_item slide enum, TS suppressions, or console.log).
|
||||
- Required macro/label evidence present: all 5 part types, position enum including by_label, hidden_at semantics, explicit restrict/cascade FKs, stacking resolver (`filter` → `map` → `values` → `all`), bundle export macro injection, German UI labels, and test IDs on macro/label picker/icon components.
|
||||
- Unaccounted grep output for `ArrangementConfigurator.vue`, `ArrangementDialog.vue`, and `SongEditModal.vue` is explained by planned SongGroup → Label rename work, not unrelated scope creep.
|
||||
45
AGENTS.md
45
AGENTS.md
|
|
@ -113,40 +113,49 @@ ## Build, Test, Lint Commands
|
|||
|
||||
### pp-planer (Laravel App)
|
||||
|
||||
Local dev runs in **DDEV** (Docker). Site URL: `https://pp-planer.ddev.site`.
|
||||
|
||||
```bash
|
||||
# First-time setup
|
||||
composer setup # install, .env, key:generate, migrate, npm install, npm build
|
||||
# Easy onboarding wrappers (no DDEV knowledge required):
|
||||
./start_dev.sh # ddev start + spawns dev workers in background
|
||||
./stop_dev.sh # stops workers and DDEV (use --keep-ddev to leave DDEV running)
|
||||
|
||||
# Dev server (Laravel + Vite + Queue + Logs)
|
||||
composer dev
|
||||
# Or use DDEV directly:
|
||||
ddev start # composer install, key:generate, migrate, npm install, npm run build
|
||||
ddev dev # queue + log tail + vite HMR (foreground)
|
||||
ddev stop # stop the project
|
||||
|
||||
# Build frontend
|
||||
npm run build
|
||||
# Open a shell inside the web container
|
||||
ddev ssh
|
||||
|
||||
# Run ALL PHP tests (206 tests, clears config cache first)
|
||||
composer test
|
||||
php artisan test
|
||||
# Frontend
|
||||
ddev npm run build
|
||||
ddev npm run dev
|
||||
|
||||
# Run ALL PHP tests (clears config cache first)
|
||||
ddev composer test
|
||||
ddev exec php artisan test
|
||||
|
||||
# Single test file
|
||||
php artisan test tests/Feature/ServiceControllerTest.php
|
||||
ddev exec php artisan test tests/Feature/ServiceControllerTest.php
|
||||
|
||||
# Single test method
|
||||
php artisan test --filter=test_service_kann_abgeschlossen_werden
|
||||
ddev exec php artisan test --filter=test_service_kann_abgeschlossen_werden
|
||||
|
||||
# Test suite
|
||||
php artisan test --testsuite=Feature
|
||||
php artisan test --testsuite=Unit
|
||||
ddev exec php artisan test --testsuite=Feature
|
||||
ddev exec php artisan test --testsuite=Unit
|
||||
|
||||
# PHP formatting (Laravel Pint, default preset — no pint.json)
|
||||
./vendor/bin/pint
|
||||
./vendor/bin/pint --test # check only
|
||||
ddev exec ./vendor/bin/pint
|
||||
ddev exec ./vendor/bin/pint --test # check only
|
||||
|
||||
# E2E tests (requires dev server at http://pp-planer.test)
|
||||
# E2E tests (requires `ddev start` running; baseURL is https://pp-planer.ddev.site)
|
||||
npx playwright test
|
||||
npx playwright test tests/e2e/service-list.spec.ts
|
||||
|
||||
# Migrations
|
||||
php artisan migrate
|
||||
ddev exec php artisan migrate
|
||||
```
|
||||
|
||||
### propresenter-work (Parser Module)
|
||||
|
|
@ -241,7 +250,7 @@ ### ProPresenter Parser Tests (PHPUnit 11)
|
|||
|
||||
### E2E Tests (Playwright)
|
||||
|
||||
- TypeScript in `tests/e2e/`, baseURL `http://pp-planer.test`
|
||||
- TypeScript in `tests/e2e/`, baseURL `https://pp-planer.ddev.site` (HTTPS, `ignoreHTTPSErrors: true` set in `playwright.config.ts`)
|
||||
- Auth via `auth.setup.ts` (XSRF token + `/dev-login` endpoint, saves state to `.auth/user.json`)
|
||||
- Selectors: `page.getByTestId('...')`, `page.getByText('...')`, `page.getByRole('...')`
|
||||
- German text assertions: `await expect(page.getByText('Mit ChurchTools anmelden')).toBeVisible()`
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ArrangementController extends Controller
|
||||
{
|
||||
|
|
@ -23,17 +23,24 @@ public function store(Request $request, Song $song): RedirectResponse
|
|||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$groups = $song->groups()->orderBy('order')->get();
|
||||
$rows = $groups->map(fn ($group, $index) => [
|
||||
$defaultArr = $song->arrangements()->where('is_default', true)->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
|
||||
|
||||
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_group_id' => $group->id,
|
||||
'label_id' => $al->label_id,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementGroups()->insert($rows);
|
||||
$arrangement->arrangementLabels()->insert($rows);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -47,14 +54,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
|
|||
]);
|
||||
|
||||
DB::transaction(function () use ($arrangement, $data): void {
|
||||
$arrangement->loadMissing('arrangementGroups');
|
||||
$arrangement->loadMissing('arrangementLabels');
|
||||
|
||||
$clone = $arrangement->song->arrangements()->create([
|
||||
'name' => $data['name'],
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$this->cloneGroups($arrangement, $clone);
|
||||
$this->cloneArrangementLabels($arrangement, $clone);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde geklont.');
|
||||
|
|
@ -64,33 +71,22 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
{
|
||||
$data = $request->validate([
|
||||
'groups' => ['array'],
|
||||
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
|
||||
'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'],
|
||||
'groups.*.order' => ['required', 'integer', 'min:1'],
|
||||
'group_colors' => ['sometimes', 'array'],
|
||||
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
]);
|
||||
|
||||
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
|
||||
$uniqueGroupIds = $groupIds->unique()->values();
|
||||
$labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
|
||||
|
||||
$validGroupIds = $arrangement->song->groups()
|
||||
->whereIn('id', $uniqueGroupIds)
|
||||
->pluck('id');
|
||||
DB::transaction(function () use ($arrangement, $labelIds, $data): void {
|
||||
$arrangement->arrangementLabels()->delete();
|
||||
|
||||
if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($arrangement, $groupIds, $data): void {
|
||||
$arrangement->arrangementGroups()->delete();
|
||||
|
||||
$rows = $groupIds
|
||||
$rows = $labelIds
|
||||
->values()
|
||||
->map(fn (int $songGroupId, int $index) => [
|
||||
->map(fn (int $labelId, int $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_group_id' => $songGroupId,
|
||||
'label_id' => $labelId,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
|
@ -98,14 +94,12 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
|
|||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementGroups()->insert($rows);
|
||||
$arrangement->arrangementLabels()->insert($rows);
|
||||
}
|
||||
|
||||
if (! empty($data['group_colors'])) {
|
||||
foreach ($data['group_colors'] as $groupId => $color) {
|
||||
$arrangement->song->groups()
|
||||
->whereKey((int) $groupId)
|
||||
->update(['color' => $color]);
|
||||
foreach ($data['group_colors'] as $labelId => $color) {
|
||||
Label::whereKey((int) $labelId)->update(['color' => $color]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -136,28 +130,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
|
|||
return back()->with('success', 'Arrangement wurde gelöscht.');
|
||||
}
|
||||
|
||||
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
|
||||
private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void
|
||||
{
|
||||
if ($source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$groups = $source->arrangementGroups
|
||||
$arrangementLabels = $source->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values();
|
||||
|
||||
$rows = $groups
|
||||
->map(fn ($arrangementGroup) => [
|
||||
$rows = $arrangementLabels
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'song_arrangement_id' => $target->id,
|
||||
'song_group_id' => $arrangementGroup->song_group_id,
|
||||
'order' => $arrangementGroup->order,
|
||||
'label_id' => $arrangementLabel->label_id,
|
||||
'order' => $arrangementLabel->order,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$target->arrangementGroups()->insert($rows);
|
||||
$target->arrangementLabels()->insert($rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
app/Http/Controllers/LabelImportController.php
Normal file
37
app/Http/Controllers/LabelImportController.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\LabelsImportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class LabelImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LabelsImportService $importService,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'max:5120']]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getPathname();
|
||||
|
||||
try {
|
||||
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Labels-Datei ist.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'new' => $result->newCount,
|
||||
'updated' => $result->updatedCount,
|
||||
'total' => $result->totalInFile,
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/MacroAssignmentController.php
Normal file
86
app/Http/Controllers/MacroAssignmentController.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\MacroCollection;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class MacroAssignmentController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('Settings', [
|
||||
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
|
||||
'macros' => Macro::with('collections')->orderBy('name')->get(),
|
||||
'labels' => Label::orderBy('name')->get(),
|
||||
'collections' => MacroCollection::orderBy('name')->get(),
|
||||
'last_macros_import' => [
|
||||
'at' => Setting::get('macros_last_imported_at'),
|
||||
'filename' => Setting::get('macros_last_imported_filename'),
|
||||
],
|
||||
'last_labels_import' => [
|
||||
'at' => Setting::get('labels_last_imported_at'),
|
||||
'filename' => Setting::get('labels_last_imported_filename'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'macro_id' => ['required', 'integer', 'exists:macros,id'],
|
||||
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$assignment = MacroAssignment::create($validated);
|
||||
|
||||
return response()->json(['id' => $assignment->id, 'success' => true]);
|
||||
}
|
||||
|
||||
public function update(Request $request, MacroAssignment $macroAssignment): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'macro_id' => ['sometimes', 'integer', 'exists:macros,id'],
|
||||
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['sometimes', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$macroAssignment->update($validated);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy(MacroAssignment $macroAssignment): JsonResponse
|
||||
{
|
||||
$macroAssignment->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'assignments' => ['required', 'array'],
|
||||
'assignments.*.id' => ['required', 'integer', 'exists:macro_assignments,id'],
|
||||
'assignments.*.order' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
foreach ($validated['assignments'] as $item) {
|
||||
MacroAssignment::where('id', $item['id'])->update(['order' => $item['order']]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/MacroImportController.php
Normal file
41
app/Http/Controllers/MacroImportController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\MacrosImportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class MacroImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MacrosImportService $importService,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'max:5120']]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getPathname();
|
||||
|
||||
try {
|
||||
$result = $this->importService->import($tempPath, $file->getClientOriginalName());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Makro-Datei ist.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => [
|
||||
'new' => $result->new,
|
||||
'updated' => $result->updated,
|
||||
'disabled' => $result->disabled,
|
||||
're_enabled' => $result->reEnabled,
|
||||
],
|
||||
'warnings' => $result->warnings,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,15 +49,23 @@ public function importPro(Request $request): JsonResponse
|
|||
|
||||
public function downloadPro(Song $song): BinaryFileResponse
|
||||
{
|
||||
if ($song->groups()->count() === 0) {
|
||||
if ($this->countSongLabels($song) === 0) {
|
||||
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
|
||||
}
|
||||
|
||||
$exportService = new ProExportService;
|
||||
$exportService = app(ProExportService::class);
|
||||
$tempPath = $exportService->generateProFile($song);
|
||||
|
||||
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||
|
||||
return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
private function countSongLabels(Song $song): int
|
||||
{
|
||||
return $song->arrangements()
|
||||
->withCount('arrangementLabels')
|
||||
->get()
|
||||
->sum('arrangement_labels_count');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Slide;
|
||||
use App\Models\Song;
|
||||
use App\Services\AgendaMatcherService;
|
||||
use App\Services\MacroResolutionService;
|
||||
use App\Services\ProBundleExportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -129,15 +131,13 @@ public function edit(Service $service): Response
|
|||
$service->load([
|
||||
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
||||
'serviceSongs.song',
|
||||
'serviceSongs.song.groups',
|
||||
'serviceSongs.song.arrangements.arrangementGroups.group',
|
||||
'serviceSongs.song.arrangements.arrangementLabels.label',
|
||||
'serviceSongs.arrangement',
|
||||
'slides',
|
||||
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'agendaItems.slides',
|
||||
'agendaItems.serviceSong.song.groups.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
|
||||
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'agendaItems.serviceSong.arrangement.arrangementLabels.label',
|
||||
]);
|
||||
|
||||
$songsCatalog = Song::query()
|
||||
|
|
@ -227,6 +227,34 @@ public function edit(Service $service): Response
|
|||
return $arr;
|
||||
}, $filteredItems);
|
||||
|
||||
// Macro resolution per part type (for icons + Anpassen/Standard panel)
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$macros_per_part = [];
|
||||
foreach (['information', 'moderation', 'sermon', 'song', 'agenda_item'] as $partType) {
|
||||
$assignments = $resolver->resolveAssignmentsForPart($service, $partType);
|
||||
$isOverridden = ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
->exists();
|
||||
$hasWarning = $assignments->contains(
|
||||
fn ($a) => $a->macro?->isHidden() || ($a->position === 'by_label' && $a->label?->isHidden())
|
||||
);
|
||||
$macros_per_part[$partType] = [
|
||||
'count' => $assignments->count(),
|
||||
'is_overridden' => $isOverridden,
|
||||
'has_warning' => $hasWarning,
|
||||
'assignments' => $assignments->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'macro_id' => $a->macro_id,
|
||||
'macro_name' => $a->macro?->name,
|
||||
'macro_color' => $a->macro?->color,
|
||||
'macro_hidden' => $a->macro?->isHidden(),
|
||||
'position' => $a->position,
|
||||
'label_id' => $a->label_id,
|
||||
'label_name' => $a->label?->name,
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return Inertia::render('Services/Edit', [
|
||||
'service' => [
|
||||
'id' => $service->id,
|
||||
|
|
@ -253,15 +281,7 @@ public function edit(Service $service): Response
|
|||
'title' => $ss->song->title,
|
||||
'ccli_id' => $ss->song->ccli_id,
|
||||
'has_translation' => $ss->song->has_translation,
|
||||
'groups' => $ss->song->groups
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
]),
|
||||
'groups' => $this->collectSongLabels($ss->song),
|
||||
'arrangements' => $ss->song->arrangements
|
||||
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
|
||||
->values()
|
||||
|
|
@ -269,13 +289,13 @@ public function edit(Service $service): Response
|
|||
'id' => $arrangement->id,
|
||||
'name' => $arrangement->name,
|
||||
'is_default' => $arrangement->is_default,
|
||||
'groups' => $arrangement->arrangementGroups
|
||||
'groups' => $arrangement->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($arrangementGroup) => [
|
||||
'id' => $arrangementGroup->group?->id,
|
||||
'name' => $arrangementGroup->group?->name,
|
||||
'color' => $arrangementGroup->group?->color,
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'id' => $arrangementLabel->label?->id,
|
||||
'name' => $arrangementLabel->label?->name,
|
||||
'color' => $arrangementLabel->label?->color,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values(),
|
||||
|
|
@ -302,6 +322,7 @@ public function edit(Service $service): Response
|
|||
'title' => $nextService->title,
|
||||
'date' => $nextService->date?->toDateString(),
|
||||
] : null,
|
||||
'macros_per_part' => $macros_per_part,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -412,4 +433,25 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
|
|||
)
|
||||
->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
private function collectSongLabels(Song $song): \Illuminate\Support\Collection
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $defaultArr->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($arrangementLabel) => [
|
||||
'id' => $arrangementLabel->label?->id,
|
||||
'name' => $arrangementLabel->label?->name,
|
||||
'color' => $arrangementLabel->label?->color,
|
||||
'order' => $arrangementLabel->order,
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
app/Http/Controllers/ServiceMacroOverrideController.php
Normal file
94
app/Http/Controllers/ServiceMacroOverrideController.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceMacroAssignment;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceMacroOverrideController extends Controller
|
||||
{
|
||||
public function store(Request $request, Service $service): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
]);
|
||||
|
||||
ServiceMacroOverride::firstOrCreate([
|
||||
'service_id' => $service->id,
|
||||
'part_type' => $validated['part_type'],
|
||||
]);
|
||||
|
||||
$globals = MacroAssignment::where('part_type', $validated['part_type'])->orderBy('order')->get();
|
||||
foreach ($globals as $global) {
|
||||
ServiceMacroAssignment::firstOrCreate([
|
||||
'service_id' => $service->id,
|
||||
'part_type' => $validated['part_type'],
|
||||
'macro_id' => $global->macro_id,
|
||||
'position' => $global->position,
|
||||
'label_id' => $global->label_id,
|
||||
'order' => $global->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy(Service $service, Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
]);
|
||||
|
||||
ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $validated['part_type'])
|
||||
->delete();
|
||||
|
||||
ServiceMacroAssignment::where('service_id', $service->id)
|
||||
->where('part_type', $validated['part_type'])
|
||||
->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function storeAssignment(Request $request, Service $service): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'macro_id' => ['required', 'integer', 'exists:macros,id'],
|
||||
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$assignment = ServiceMacroAssignment::create([
|
||||
'service_id' => $service->id,
|
||||
...$validated,
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $assignment->id, 'success' => true]);
|
||||
}
|
||||
|
||||
public function updateAssignment(Request $request, Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
'order' => ['sometimes', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$serviceMacroAssignment->update($validated);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroyAssignment(Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse
|
||||
{
|
||||
$serviceMacroAssignment->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
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;
|
||||
|
|
@ -10,11 +14,7 @@
|
|||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
private const MACRO_KEYS = [
|
||||
'macro_name',
|
||||
'macro_uuid',
|
||||
'macro_collection_name',
|
||||
'macro_collection_uuid',
|
||||
private const AGENDA_KEYS = [
|
||||
'agenda_start_title',
|
||||
'agenda_end_title',
|
||||
'agenda_announcement_position',
|
||||
|
|
@ -24,19 +24,31 @@ class SettingsController extends Controller
|
|||
public function index(): Response
|
||||
{
|
||||
$settings = [];
|
||||
foreach (self::MACRO_KEYS as $key) {
|
||||
foreach (self::AGENDA_KEYS as $key) {
|
||||
$settings[$key] = Setting::get($key);
|
||||
}
|
||||
|
||||
return Inertia::render('Settings', [
|
||||
'settings' => $settings,
|
||||
'assignments' => MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(),
|
||||
'macros' => Macro::with('collections')->orderBy('name')->get(),
|
||||
'labels' => Label::orderBy('name')->get(),
|
||||
'collections' => MacroCollection::with('macros')->orderBy('name')->get(),
|
||||
'last_macros_import' => [
|
||||
'at' => Setting::get('macros_last_imported_at'),
|
||||
'filename' => Setting::get('macros_last_imported_filename'),
|
||||
],
|
||||
'last_labels_import' => [
|
||||
'at' => Setting::get('labels_last_imported_at'),
|
||||
'filename' => Setting::get('labels_last_imported_filename'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'key' => ['required', 'string', 'in:'.implode(',', self::MACRO_KEYS)],
|
||||
'key' => ['required', 'string', 'in:'.implode(',', self::AGENDA_KEYS)],
|
||||
'value' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ public function __construct(
|
|||
private readonly SongService $songService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Alle Songs auflisten (paginiert, durchsuchbar).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Song::query();
|
||||
|
|
@ -53,15 +50,11 @@ public function index(Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
|
||||
*/
|
||||
public function store(SongRequest $request): JsonResponse
|
||||
{
|
||||
$song = DB::transaction(function () use ($request) {
|
||||
$song = Song::create($request->validated());
|
||||
|
||||
$this->songService->createDefaultGroups($song);
|
||||
$this->songService->createDefaultArrangement($song);
|
||||
|
||||
return $song;
|
||||
|
|
@ -69,16 +62,13 @@ public function store(SongRequest $request): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich erstellt',
|
||||
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song mit Gruppen, Slides und Arrangements anzeigen.
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
|
||||
$song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
|
|
@ -89,9 +79,6 @@ public function show(int $id): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-Metadaten aktualisieren.
|
||||
*/
|
||||
public function update(SongRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$song = Song::find($id);
|
||||
|
|
@ -104,13 +91,10 @@ public function update(SongRequest $request, int $id): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich aktualisiert',
|
||||
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song soft-löschen.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::find($id);
|
||||
|
|
@ -126,11 +110,35 @@ public function destroy(int $id): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-Detail formatieren.
|
||||
*/
|
||||
private function formatSongDetail(Song $song): array
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true);
|
||||
|
||||
$groupsPayload = [];
|
||||
if ($defaultArr !== null) {
|
||||
$groupsPayload = $defaultArr->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($al) => [
|
||||
'id' => $al->label?->id,
|
||||
'name' => $al->label?->name,
|
||||
'color' => $al->label?->color,
|
||||
'order' => $al->order,
|
||||
'slides' => $al->label
|
||||
? $al->label->songSlides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes,
|
||||
])->toArray()
|
||||
: [],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $song->id,
|
||||
'title' => $song->title,
|
||||
|
|
@ -144,27 +152,15 @@ private function formatSongDetail(Song $song): array
|
|||
'last_used_in_service' => $song->last_used_in_service,
|
||||
'created_at' => $song->created_at->toDateTimeString(),
|
||||
'updated_at' => $song->updated_at->toDateTimeString(),
|
||||
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
'groups' => $groupsPayload,
|
||||
'arrangements' => $song->arrangements->map(fn ($arr) => [
|
||||
'id' => $arr->id,
|
||||
'name' => $arr->name,
|
||||
'is_default' => $arr->is_default,
|
||||
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
|
||||
'id' => $ag->id,
|
||||
'song_group_id' => $ag->song_group_id,
|
||||
'order' => $ag->order,
|
||||
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
|
||||
'id' => $al->id,
|
||||
'label_id' => $al->label_id,
|
||||
'order' => $al->order,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -57,21 +57,25 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
|
|||
private function buildGroupsInOrder(SongArrangement $arrangement): array
|
||||
{
|
||||
$arrangement->load([
|
||||
'arrangementGroups' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementLabels' => fn ($query) => $query->orderBy('order'),
|
||||
'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'),
|
||||
]);
|
||||
|
||||
return $arrangement->arrangementGroups->map(function ($arrangementGroup) {
|
||||
$group = $arrangementGroup->group;
|
||||
return $arrangement->arrangementLabels->map(function ($arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($label === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $group->name,
|
||||
'color' => $group->color ?? '#6b7280',
|
||||
'slides' => $group->slides->map(fn ($slide) => [
|
||||
'name' => $label->name,
|
||||
'color' => $label->color ?? '#6b7280',
|
||||
'slides' => $label->songSlides->map(fn ($slide) => [
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values()->all(),
|
||||
];
|
||||
})->values()->all();
|
||||
})->filter()->values()->all();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,41 +18,48 @@ public function __construct(
|
|||
public function page(Song $song): Response
|
||||
{
|
||||
$song->load([
|
||||
'groups' => fn ($query) => $query
|
||||
->orderBy('order')
|
||||
->with([
|
||||
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
|
||||
]),
|
||||
'arrangements' => fn ($q) => $q->where('is_default', true),
|
||||
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'),
|
||||
'arrangements.arrangementLabels.label.songSlides',
|
||||
]);
|
||||
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
$groups = collect();
|
||||
if ($defaultArr !== null) {
|
||||
$groups = $defaultArr->arrangementLabels
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($al) => [
|
||||
'id' => $al->label?->id,
|
||||
'name' => $al->label?->name,
|
||||
'color' => $al->label?->color,
|
||||
'order' => $al->order,
|
||||
'slides' => $al->label
|
||||
? $al->label->songSlides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values()
|
||||
: collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
return Inertia::render('Songs/Translate', [
|
||||
'song' => [
|
||||
'id' => $song->id,
|
||||
'title' => $song->title,
|
||||
'ccli_id' => $song->ccli_id,
|
||||
'has_translation' => $song->has_translation,
|
||||
'groups' => $song->groups->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
'slides' => $group->slides->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
])->values(),
|
||||
])->values(),
|
||||
'groups' => $groups,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL abrufen und Text zum Prüfen zurückgeben.
|
||||
*
|
||||
* Der Text wird NICHT automatisch gespeichert — der Benutzer
|
||||
* prüft ihn zuerst und importiert dann explizit.
|
||||
*/
|
||||
public function fetchUrl(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
|
|
@ -72,11 +79,6 @@ public function fetchUrl(Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungstext für einen Song importieren.
|
||||
*
|
||||
* Verteilt den Text zeilenweise auf die Slides des Songs.
|
||||
*/
|
||||
public function import(int $songId, Request $request): JsonResponse
|
||||
{
|
||||
$song = Song::find($songId);
|
||||
|
|
@ -98,9 +100,6 @@ public function import(int $songId, Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzung eines Songs komplett entfernen.
|
||||
*/
|
||||
public function destroy(int $songId): JsonResponse
|
||||
{
|
||||
$song = Song::find($songId);
|
||||
|
|
|
|||
42
app/Models/Label.php
Normal file
42
app/Models/Label.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Label extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
'hidden_at',
|
||||
'last_imported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'hidden_at' => 'datetime',
|
||||
'last_imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function songSlides(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSlide::class);
|
||||
}
|
||||
|
||||
public function macroAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MacroAssignment::class);
|
||||
}
|
||||
|
||||
public function isHidden(): bool
|
||||
{
|
||||
return $this->hidden_at !== null;
|
||||
}
|
||||
}
|
||||
51
app/Models/Macro.php
Normal file
51
app/Models/Macro.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Macro extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'color',
|
||||
'trigger_on_startup',
|
||||
'image_type',
|
||||
'action_count',
|
||||
'hidden_at',
|
||||
'last_imported_at',
|
||||
'last_imported_filename',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'trigger_on_startup' => 'boolean',
|
||||
'hidden_at' => 'datetime',
|
||||
'last_imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function collections(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(MacroCollection::class, 'macro_collection_macros')
|
||||
->withPivot('order')
|
||||
->orderBy('macro_collection_macros.order');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MacroAssignment::class);
|
||||
}
|
||||
|
||||
public function isHidden(): bool
|
||||
{
|
||||
return $this->hidden_at !== null;
|
||||
}
|
||||
}
|
||||
27
app/Models/MacroAssignment.php
Normal file
27
app/Models/MacroAssignment.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MacroAssignment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'part_type',
|
||||
'macro_id',
|
||||
'position',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function macro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Macro::class);
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/MacroCollection.php
Normal file
29
app/Models/MacroCollection.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class MacroCollection extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'last_imported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function macros(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Macro::class, 'macro_collection_macros')
|
||||
->withPivot('order')
|
||||
->orderBy('macro_collection_macros.order');
|
||||
}
|
||||
}
|
||||
33
app/Models/ServiceMacroAssignment.php
Normal file
33
app/Models/ServiceMacroAssignment.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServiceMacroAssignment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'part_type',
|
||||
'macro_id',
|
||||
'position',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function macro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Macro::class);
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/ServiceMacroOverride.php
Normal file
26
app/Models/ServiceMacroOverride.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServiceMacroOverride extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'part_type',
|
||||
];
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceMacroAssignment::class, 'service_id', 'service_id')
|
||||
->where('part_type', $this->part_type);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,11 +33,6 @@ protected function casts(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function groups(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongGroup::class);
|
||||
}
|
||||
|
||||
public function arrangements(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangement::class);
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ public function song(): BelongsTo
|
|||
return $this->belongsTo(Song::class);
|
||||
}
|
||||
|
||||
public function arrangementGroups(): HasMany
|
||||
public function arrangementLabels(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementGroup::class);
|
||||
return $this->hasMany(SongArrangementLabel::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function serviceSongs(): HasMany
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SongArrangementGroup extends Model
|
||||
class SongArrangementLabel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_arrangement_id',
|
||||
'song_group_id',
|
||||
'label_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
|
|
@ -21,8 +21,8 @@ public function arrangement(): BelongsTo
|
|||
return $this->belongsTo(SongArrangement::class, 'song_arrangement_id');
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SongGroup::class, 'song_group_id');
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SongGroup extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_id',
|
||||
'name',
|
||||
'color',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function song(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Song::class);
|
||||
}
|
||||
|
||||
public function slides(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongSlide::class);
|
||||
}
|
||||
|
||||
public function arrangementGroups(): HasMany
|
||||
{
|
||||
return $this->hasMany(SongArrangementGroup::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,15 @@ class SongSlide extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'song_group_id',
|
||||
'label_id',
|
||||
'order',
|
||||
'text_content',
|
||||
'text_content_translated',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SongGroup::class, 'song_group_id');
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ public function __construct(
|
|||
private readonly ?Closure $songFetcher = null,
|
||||
private readonly ?Closure $agendaFetcher = null,
|
||||
private readonly ?Closure $eventServiceFetcher = null,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function sync(): array
|
||||
{
|
||||
|
|
|
|||
12
app/Services/DTO/LabelImportResult.php
Normal file
12
app/Services/DTO/LabelImportResult.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DTO;
|
||||
|
||||
final class LabelImportResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $newCount,
|
||||
public readonly int $updatedCount,
|
||||
public readonly int $totalInFile,
|
||||
) {}
|
||||
}
|
||||
14
app/Services/DTO/MacroImportResult.php
Normal file
14
app/Services/DTO/MacroImportResult.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DTO;
|
||||
|
||||
final class MacroImportResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $new,
|
||||
public readonly int $updated,
|
||||
public readonly int $disabled,
|
||||
public readonly int $reEnabled,
|
||||
public readonly array $warnings,
|
||||
) {}
|
||||
}
|
||||
49
app/Services/LabelsImportService.php
Normal file
49
app/Services/LabelsImportService.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DTO\LabelImportResult;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\LabelsFileReader;
|
||||
|
||||
class LabelsImportService
|
||||
{
|
||||
public function import(string $filePath, string $originalFilename): LabelImportResult
|
||||
{
|
||||
$library = LabelsFileReader::read($filePath);
|
||||
$newCount = 0;
|
||||
$updatedCount = 0;
|
||||
|
||||
DB::transaction(function () use ($library, &$newCount, &$updatedCount): void {
|
||||
foreach ($library->getLabels() as $parserLabel) {
|
||||
$name = $parserLabel->getName();
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$color = $parserLabel->getColorHex();
|
||||
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
|
||||
if ($existing === null) {
|
||||
Label::create([
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'last_imported_at' => now(),
|
||||
]);
|
||||
$newCount++;
|
||||
} else {
|
||||
$existing->update([
|
||||
'color' => $color,
|
||||
'last_imported_at' => now(),
|
||||
]);
|
||||
$updatedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Setting::set('labels_last_imported_at', now()->toIso8601String());
|
||||
Setting::set('labels_last_imported_filename', $originalFilename);
|
||||
|
||||
return new LabelImportResult($newCount, $updatedCount, count($library->getLabels()));
|
||||
}
|
||||
}
|
||||
85
app/Services/MacroResolutionService.php
Normal file
85
app/Services/MacroResolutionService.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceMacroAssignment;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class MacroResolutionService
|
||||
{
|
||||
/**
|
||||
* Returns active (non-hidden) assignments for a given service + part type.
|
||||
* Uses service-specific assignments if an override exists, otherwise global defaults.
|
||||
*/
|
||||
public function resolveAssignmentsForPart(Service $service, string $partType): Collection
|
||||
{
|
||||
$hasOverride = ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
->exists();
|
||||
|
||||
if ($hasOverride) {
|
||||
$rows = ServiceMacroAssignment::with(['macro', 'label'])
|
||||
->where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
->orderBy('order')
|
||||
->get();
|
||||
} else {
|
||||
$rows = MacroAssignment::with(['macro', 'label'])
|
||||
->where('part_type', $partType)
|
||||
->orderBy('order')
|
||||
->get();
|
||||
}
|
||||
|
||||
return $rows
|
||||
->reject(fn ($r) => $r->macro === null || $r->macro->isHidden())
|
||||
->reject(fn ($r) => $r->position === 'by_label' && ($r->label === null || $r->label->isHidden()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the macro export data for macros that apply to a specific slide.
|
||||
*
|
||||
* @param array $slideContext ['index' => int, 'total' => int, 'label_id' => int|null]
|
||||
* @return array<int, array{name: string, uuid: string, collectionName: string, collectionUuid: string}>
|
||||
*/
|
||||
public function macrosForSlide(Service $service, string $partType, array $slideContext): array
|
||||
{
|
||||
$assignments = $this->resolveAssignmentsForPart($service, $partType);
|
||||
|
||||
$matched = $assignments->filter(function ($a) use ($slideContext) {
|
||||
return match ($a->position) {
|
||||
'all_slides' => true,
|
||||
'first_slide' => $slideContext['index'] === 0,
|
||||
'last_slide' => $slideContext['index'] === $slideContext['total'] - 1,
|
||||
'by_label' => isset($slideContext['label_id'])
|
||||
&& (int) $a->label_id === (int) $slideContext['label_id'],
|
||||
default => false,
|
||||
};
|
||||
});
|
||||
|
||||
return $matched->map(fn ($a) => $this->toExportArray($a->macro))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of active assignments for a service + part (for UI badges).
|
||||
*/
|
||||
public function countAssignmentsForPart(Service $service, string $partType): int
|
||||
{
|
||||
return $this->resolveAssignmentsForPart($service, $partType)->count();
|
||||
}
|
||||
|
||||
private function toExportArray(Macro $macro): array
|
||||
{
|
||||
$collection = $macro->collections()->first();
|
||||
|
||||
return [
|
||||
'name' => $macro->name,
|
||||
'uuid' => $macro->uuid,
|
||||
'collectionName' => $collection?->name ?? '--MAIN--',
|
||||
'collectionUuid' => $collection?->uuid ?? '8D02FC57-83F8-4042-9B90-81C229728426',
|
||||
];
|
||||
}
|
||||
}
|
||||
110
app/Services/MacrosImportService.php
Normal file
110
app/Services/MacrosImportService.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\MacroCollection;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DTO\MacroImportResult;
|
||||
use App\Support\MacroColorConverter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\MacrosFileReader;
|
||||
|
||||
class MacrosImportService
|
||||
{
|
||||
public function import(string $filePath, string $originalFilename): MacroImportResult
|
||||
{
|
||||
$library = MacrosFileReader::read($filePath);
|
||||
$stats = ['new' => 0, 'updated' => 0, 'disabled' => 0, 'reEnabled' => 0];
|
||||
$importedUuids = [];
|
||||
|
||||
DB::transaction(function () use ($library, &$stats, &$importedUuids, $originalFilename): void {
|
||||
foreach ($library->getMacros() as $parserMacro) {
|
||||
$uuid = strtoupper($parserMacro->getUuid());
|
||||
if ($uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$importedUuids[] = $uuid;
|
||||
$color = MacroColorConverter::fromRgba($parserMacro->getColor());
|
||||
$data = [
|
||||
'uuid' => $uuid,
|
||||
'name' => $parserMacro->getName(),
|
||||
'color' => $color,
|
||||
'trigger_on_startup' => $parserMacro->getTriggerOnStartup(),
|
||||
'image_type' => $parserMacro->getImageType(),
|
||||
'action_count' => $parserMacro->getActionCount(),
|
||||
'last_imported_at' => now(),
|
||||
'last_imported_filename' => $originalFilename,
|
||||
'hidden_at' => null,
|
||||
];
|
||||
|
||||
$existing = Macro::where('uuid', $uuid)->first();
|
||||
if ($existing === null) {
|
||||
Macro::create($data);
|
||||
$stats['new']++;
|
||||
} else {
|
||||
$wasHidden = $existing->isHidden();
|
||||
$existing->update($data);
|
||||
if ($wasHidden) {
|
||||
$stats['reEnabled']++;
|
||||
} else {
|
||||
$stats['updated']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($importedUuids)) {
|
||||
$stats['disabled'] = Macro::whereNotIn('uuid', $importedUuids)
|
||||
->whereNull('hidden_at')
|
||||
->update(['hidden_at' => now()]);
|
||||
}
|
||||
|
||||
foreach ($library->getCollections() as $parserCollection) {
|
||||
$collUuid = strtoupper($parserCollection->getUuid());
|
||||
if ($collUuid === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$collection = MacroCollection::updateOrCreate(
|
||||
['uuid' => $collUuid],
|
||||
['name' => $parserCollection->getName(), 'last_imported_at' => now()],
|
||||
);
|
||||
|
||||
$collection->macros()->detach();
|
||||
foreach ($parserCollection->getMacroUuids() as $idx => $macroUuid) {
|
||||
$macro = Macro::where('uuid', strtoupper($macroUuid))->first();
|
||||
if ($macro) {
|
||||
$collection->macros()->attach($macro->id, ['order' => $idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Setting::set('macros_last_imported_at', now()->toIso8601String());
|
||||
Setting::set('macros_last_imported_filename', $originalFilename);
|
||||
|
||||
$warnings = $this->buildAssignmentWarnings();
|
||||
|
||||
return new MacroImportResult(
|
||||
$stats['new'],
|
||||
$stats['updated'],
|
||||
$stats['disabled'],
|
||||
$stats['reEnabled'],
|
||||
$warnings,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildAssignmentWarnings(): array
|
||||
{
|
||||
return MacroAssignment::whereHas('macro', fn ($q) => $q->whereNotNull('hidden_at'))
|
||||
->with('macro')
|
||||
->get()
|
||||
->map(fn ($a) => [
|
||||
'macro_name' => $a->macro->name,
|
||||
'macro_uuid' => $a->macro->uuid,
|
||||
'part_type' => $a->part_type,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,11 @@ public function generatePlaylist(Service $service): array
|
|||
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
||||
->where('is_before_event', false)
|
||||
->orderBy('sort_order')
|
||||
->with(['slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'serviceSong.song.groups.slides', 'serviceSong.arrangement.arrangementGroups.group'])
|
||||
->with([
|
||||
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
|
||||
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
'serviceSong.arrangement.arrangementLabels.label',
|
||||
])
|
||||
->get();
|
||||
|
||||
if ($agendaItems->isEmpty()) {
|
||||
|
|
@ -49,7 +53,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$announcementPatterns = Setting::get('agenda_announcement_position');
|
||||
$announcementInserted = false;
|
||||
|
||||
$exportService = new ProExportService;
|
||||
$exportService = app(ProExportService::class);
|
||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
|
|
@ -80,13 +84,13 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
if ($serviceSong->song_id && $serviceSong->song) {
|
||||
$song = $serviceSong->song;
|
||||
|
||||
if ($song->groups()->count() === 0) {
|
||||
if ($this->countSongLabels($song) === 0) {
|
||||
$skippedUnmatched++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$proPath = $exportService->generateProFile($song);
|
||||
$proPath = $exportService->generateProFile($song, $service);
|
||||
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||
$destPath = $tempDir.'/'.$proFilename;
|
||||
rename($proPath, $destPath);
|
||||
|
|
@ -161,18 +165,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
*/
|
||||
private function generatePlaylistLegacy(Service $service): array
|
||||
{
|
||||
$service->loadMissing('serviceSongs.song.groups.slides');
|
||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
|
||||
|
||||
$matchedSongs = $service->serviceSongs()
|
||||
->whereNotNull('song_id')
|
||||
->orderBy('order')
|
||||
->with('song.groups.slides')
|
||||
->with('song.arrangements.arrangementLabels.label.songSlides')
|
||||
->get();
|
||||
|
||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||
$skippedEmpty = 0;
|
||||
|
||||
$exportService = new ProExportService;
|
||||
$exportService = app(ProExportService::class);
|
||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
|
|
@ -191,13 +195,13 @@ private function generatePlaylistLegacy(Service $service): array
|
|||
foreach ($matchedSongs as $serviceSong) {
|
||||
$song = $serviceSong->song;
|
||||
|
||||
if (! $song || $song->groups()->count() === 0) {
|
||||
if (! $song || $this->countSongLabels($song) === 0) {
|
||||
$skippedEmpty++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$proPath = $exportService->generateProFile($song);
|
||||
$proPath = $exportService->generateProFile($song, $service);
|
||||
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
|
||||
$destPath = $tempDir.'/'.$proFilename;
|
||||
rename($proPath, $destPath);
|
||||
|
|
@ -366,6 +370,14 @@ protected function writeProFile(string $path, string $name, array $groups, array
|
|||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||
}
|
||||
|
||||
private function countSongLabels(\App\Models\Song $song): int
|
||||
{
|
||||
return $song->arrangements()
|
||||
->withCount('arrangementLabels')
|
||||
->get()
|
||||
->sum('arrangement_labels_count');
|
||||
}
|
||||
|
||||
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||
{
|
||||
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class ProBundleExportService
|
|||
{
|
||||
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
|
||||
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
) {}
|
||||
|
||||
public function generateBundle(Service $service, string $blockType): string
|
||||
{
|
||||
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
|
||||
|
|
@ -28,15 +32,15 @@ public function generateBundle(Service $service, string $blockType): string
|
|||
|
||||
$groupName = ucfirst($blockType);
|
||||
|
||||
return $this->buildBundleFromSlides($slides, $groupName);
|
||||
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType);
|
||||
}
|
||||
|
||||
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||
{
|
||||
$agendaItem->loadMissing([
|
||||
'service',
|
||||
'slides',
|
||||
'serviceSong.song.groups.slides',
|
||||
'serviceSong.song.arrangements.arrangementGroups.group',
|
||||
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
|
||||
]);
|
||||
|
||||
$title = $agendaItem->title ?: 'Ablauf-Element';
|
||||
|
|
@ -44,11 +48,16 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
||||
$song = $agendaItem->serviceSong->song;
|
||||
|
||||
if ($song->groups()->count() === 0) {
|
||||
$labelCount = $song->arrangements()
|
||||
->withCount('arrangementLabels')
|
||||
->get()
|
||||
->sum('arrangement_labels_count');
|
||||
|
||||
if ($labelCount === 0) {
|
||||
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
|
||||
}
|
||||
|
||||
$parserSong = (new ProExportService)->generateParserSong($song);
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
||||
$proFilename = self::safeFilename($song->title).'.pro';
|
||||
|
||||
$bundle = new PresentationBundle($parserSong, $proFilename);
|
||||
|
|
@ -63,11 +72,11 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return $this->buildBundleFromSlides($slides, $title);
|
||||
return $this->buildBundleFromSlides($slides, $title, $agendaItem->service, 'agenda_item');
|
||||
}
|
||||
|
||||
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
||||
private function buildBundleFromSlides($slides, string $groupName): string
|
||||
private function buildBundleFromSlides($slides, string $groupName, ?Service $service = null, ?string $partType = null): string
|
||||
{
|
||||
$slideData = [];
|
||||
$mediaFiles = [];
|
||||
|
|
@ -86,11 +95,28 @@ private function buildBundleFromSlides($slides, string $groupName): string
|
|||
|
||||
$mediaFiles[$imageFilename] = $imageContent;
|
||||
|
||||
$slideData[] = [
|
||||
$singleSlideData = [
|
||||
'media' => $imageFilename,
|
||||
'format' => 'JPG',
|
||||
'label' => $slide->original_filename,
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$groups = [
|
||||
|
|
|
|||
|
|
@ -2,20 +2,24 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Service;
|
||||
use App\Models\Song;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
|
||||
class ProExportService
|
||||
{
|
||||
public function generateProFile(Song $song): string
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
) {}
|
||||
|
||||
public function generateProFile(Song $song, ?Service $service = null): string
|
||||
{
|
||||
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
|
||||
|
||||
ProFileGenerator::generateAndWrite(
|
||||
$tempPath,
|
||||
$song->title,
|
||||
$this->buildGroups($song),
|
||||
$this->buildGroups($song, $service),
|
||||
$this->buildArrangements($song),
|
||||
$this->buildCcliMetadata($song),
|
||||
);
|
||||
|
|
@ -23,44 +27,73 @@ public function generateProFile(Song $song): string
|
|||
return $tempPath;
|
||||
}
|
||||
|
||||
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
|
||||
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song
|
||||
{
|
||||
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
|
||||
$song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
|
||||
|
||||
return ProFileGenerator::generate(
|
||||
$song->title,
|
||||
$this->buildGroups($song),
|
||||
$this->buildGroups($song, $service),
|
||||
$this->buildArrangements($song),
|
||||
$this->buildCcliMetadata($song),
|
||||
);
|
||||
}
|
||||
|
||||
private function buildGroups(Song $song): array
|
||||
private function buildGroups(Song $song, ?Service $service = null): array
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$defaultArr->loadMissing('arrangementLabels.label.songSlides');
|
||||
|
||||
$groups = [];
|
||||
$macroData = $this->buildMacroData();
|
||||
$seenLabelIds = [];
|
||||
|
||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($label->id, $seenLabelIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$seenLabelIds[] = $label->id;
|
||||
|
||||
foreach ($song->groups->sortBy('order') as $group) {
|
||||
$slides = [];
|
||||
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
|
||||
$labelSlides = $label->songSlides->sortBy('order')->values();
|
||||
$totalSlides = $labelSlides->count();
|
||||
|
||||
foreach ($group->slides->sortBy('order') as $slide) {
|
||||
foreach ($labelSlides as $slideIndex => $slide) {
|
||||
$slideData = ['text' => $slide->text_content ?? ''];
|
||||
|
||||
if ($slide->text_content_translated) {
|
||||
$slideData['translation'] = $slide->text_content_translated;
|
||||
}
|
||||
|
||||
if ($isCopyrightGroup && $macroData) {
|
||||
$slideData['macro'] = $macroData;
|
||||
if ($service !== null) {
|
||||
$macros = $this->macroResolutionService->macrosForSlide(
|
||||
$service,
|
||||
'song',
|
||||
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id],
|
||||
);
|
||||
|
||||
if (! empty($macros)) {
|
||||
// ProPresenter parser currently supports one `macro` entry per slide; keep the first resolved macro until stacked macros are supported.
|
||||
$slideData['macro'] = $macros[0];
|
||||
}
|
||||
}
|
||||
|
||||
$slides[] = $slideData;
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
'name' => $group->name,
|
||||
'color' => ProImportService::hexToRgba($group->color),
|
||||
'name' => $label->name,
|
||||
'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
|
||||
'slides' => $slides,
|
||||
];
|
||||
}
|
||||
|
|
@ -68,32 +101,16 @@ private function buildGroups(Song $song): array
|
|||
return $groups;
|
||||
}
|
||||
|
||||
private function buildMacroData(): ?array
|
||||
{
|
||||
$name = Setting::get('macro_name');
|
||||
$uuid = Setting::get('macro_uuid');
|
||||
|
||||
if (! $name || ! $uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'uuid' => $uuid,
|
||||
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
|
||||
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildArrangements(Song $song): array
|
||||
{
|
||||
$arrangements = [];
|
||||
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
|
||||
|
||||
foreach ($song->arrangements as $arrangement) {
|
||||
$groupNames = $arrangement->arrangementGroups
|
||||
$arrangement->loadMissing('arrangementLabels.label');
|
||||
|
||||
$groupNames = $arrangement->arrangementLabels
|
||||
->sortBy('order')
|
||||
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null)
|
||||
->map(fn ($al) => $al->label?->name)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Support\MacroColorConverter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\ProFileReader;
|
||||
|
|
@ -103,28 +104,30 @@ private function upsertSong(ProSong $proSong): Song
|
|||
}
|
||||
|
||||
$song->arrangements()->each(function (SongArrangement $arr) {
|
||||
$arr->arrangementGroups()->delete();
|
||||
$arr->arrangementLabels()->delete();
|
||||
});
|
||||
$song->arrangements()->delete();
|
||||
$song->groups()->each(function (SongGroup $group) {
|
||||
$group->slides()->delete();
|
||||
});
|
||||
$song->groups()->delete();
|
||||
|
||||
$hasTranslation = false;
|
||||
$groupMap = [];
|
||||
$labelsByName = [];
|
||||
|
||||
foreach ($proSong->getGroups() as $position => $proGroup) {
|
||||
foreach ($proSong->getGroups() as $proGroup) {
|
||||
$groupName = $proGroup->getName();
|
||||
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
|
||||
|
||||
if ($existingLabel === null) {
|
||||
$color = $proGroup->getColor();
|
||||
$hexColor = $color ? self::rgbaToHex($color) : '#808080';
|
||||
$hexColor = MacroColorConverter::fromRgba($color);
|
||||
|
||||
$songGroup = $song->groups()->create([
|
||||
'name' => $proGroup->getName(),
|
||||
$existingLabel = Label::create([
|
||||
'name' => $groupName,
|
||||
'color' => $hexColor,
|
||||
'order' => $position,
|
||||
]);
|
||||
}
|
||||
|
||||
$groupMap[$proGroup->getName()] = $songGroup;
|
||||
$labelsByName[$groupName] = $existingLabel;
|
||||
|
||||
$existingLabel->songSlides()->delete();
|
||||
|
||||
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
|
||||
$translatedText = null;
|
||||
|
|
@ -134,7 +137,7 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$hasTranslation = true;
|
||||
}
|
||||
|
||||
$songGroup->slides()->create([
|
||||
$existingLabel->songSlides()->create([
|
||||
'order' => $slidePosition,
|
||||
'text_content' => $proSlide->getPlainText(),
|
||||
'text_content_translated' => $translatedText,
|
||||
|
|
@ -153,19 +156,19 @@ private function upsertSong(ProSong $proSong): Song
|
|||
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
|
||||
|
||||
foreach ($groupsInArrangement as $order => $proGroup) {
|
||||
$songGroup = $groupMap[$proGroup->getName()] ?? null;
|
||||
$label = $labelsByName[$proGroup->getName()] ?? null;
|
||||
|
||||
if ($songGroup) {
|
||||
SongArrangementGroup::create([
|
||||
if ($label) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_group_id' => $songGroup->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']);
|
||||
return $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
|
||||
}
|
||||
|
||||
public static function rgbaToHex(array $rgba): string
|
||||
|
|
|
|||
|
|
@ -2,36 +2,48 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongService
|
||||
{
|
||||
/**
|
||||
* Default-Gruppen für ein neues Lied erstellen.
|
||||
* Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
|
||||
* @return Collection<int, Label>
|
||||
*/
|
||||
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
|
||||
public function createDefaultGroups(Song $song): Collection
|
||||
{
|
||||
$defaults = [
|
||||
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
|
||||
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
|
||||
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
|
||||
['name' => 'Strophe 1', 'color' => '#3B82F6'],
|
||||
['name' => 'Refrain', 'color' => '#10B981'],
|
||||
['name' => 'Bridge', 'color' => '#F59E0B'],
|
||||
];
|
||||
|
||||
foreach ($defaults as $groupData) {
|
||||
$song->groups()->create($groupData);
|
||||
$labels = collect();
|
||||
|
||||
foreach ($defaults as $data) {
|
||||
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
|
||||
|
||||
if ($existing === null) {
|
||||
$existing = Label::create([
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $song->groups()->orderBy('order')->get();
|
||||
$labels->push($existing);
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
|
||||
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
|
||||
*/
|
||||
public function createDefaultArrangement(Song $song): SongArrangement
|
||||
{
|
||||
|
|
@ -40,16 +52,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
|
|||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$groups = $song->groups()->orderBy('order')->get();
|
||||
$labels = $this->createDefaultGroups($song);
|
||||
|
||||
foreach ($groups as $index => $group) {
|
||||
$arrangement->arrangementGroups()->create([
|
||||
'song_group_id' => $group->id,
|
||||
foreach ($labels->values() as $index => $label) {
|
||||
$arrangement->arrangementLabels()->create([
|
||||
'label_id' => $label->id,
|
||||
'order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $arrangement->load('arrangementGroups');
|
||||
return $arrangement->load('arrangementLabels.label');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,15 +75,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
|
|||
$clone->is_default = false;
|
||||
$clone->save();
|
||||
|
||||
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
|
||||
SongArrangementGroup::create([
|
||||
foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) {
|
||||
SongArrangementLabel::create([
|
||||
'song_arrangement_id' => $clone->id,
|
||||
'song_group_id' => $group->song_group_id,
|
||||
'order' => $group->order,
|
||||
'label_id' => $arrangementLabel->label_id,
|
||||
'order' => $arrangementLabel->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return $clone->load('arrangementGroups');
|
||||
return $clone->load('arrangementLabels.label');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@
|
|||
|
||||
class TranslationService
|
||||
{
|
||||
/**
|
||||
* Text von einer URL abrufen (Best-Effort).
|
||||
*
|
||||
* HTML-Tags werden entfernt, nur reiner Text zurückgegeben.
|
||||
* Bei Fehlern wird null zurückgegeben, ohne Exception.
|
||||
*/
|
||||
public function fetchFromUrl(string $url): ?string
|
||||
{
|
||||
try {
|
||||
|
|
@ -33,29 +27,30 @@ public function fetchFromUrl(string $url): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
|
||||
*
|
||||
* Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
|
||||
* Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
|
||||
*
|
||||
* Beispiel:
|
||||
* Slide 1 hat 4 Zeilen → bekommt die nächsten 4 Zeilen der Übersetzung
|
||||
* Slide 2 hat 2 Zeilen → bekommt die nächsten 2 Zeilen
|
||||
* Slide 3 hat 4 Zeilen → bekommt die nächsten 4 Zeilen
|
||||
*/
|
||||
public function importTranslation(Song $song, string $text): void
|
||||
{
|
||||
$translatedLines = explode("\n", $text);
|
||||
$offset = 0;
|
||||
|
||||
// Alle Gruppen nach order sortiert laden, mit Slides
|
||||
$groups = $song->groups()->orderBy('order')->with([
|
||||
'slides' => fn ($query) => $query->orderBy('order'),
|
||||
])->get();
|
||||
$defaultArr = $song->arrangements()
|
||||
->where('is_default', true)
|
||||
->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides'])
|
||||
->first();
|
||||
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group->slides as $slide) {
|
||||
if ($defaultArr === null) {
|
||||
$this->markAsTranslated($song);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||
$label = $arrangementLabel->label;
|
||||
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($label->songSlides->sortBy('order') as $slide) {
|
||||
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
|
||||
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
||||
$offset += $originalLineCount;
|
||||
|
|
@ -69,30 +64,25 @@ public function importTranslation(Song $song, string $text): void
|
|||
$this->markAsTranslated($song);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song als "hat Übersetzung" markieren.
|
||||
*/
|
||||
public function markAsTranslated(Song $song): void
|
||||
{
|
||||
$song->update(['has_translation' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzung eines Songs komplett entfernen.
|
||||
*
|
||||
* Löscht alle text_content_translated Felder und setzt has_translation auf false.
|
||||
*/
|
||||
public function removeTranslation(Song $song): void
|
||||
{
|
||||
// Alle Slides des Songs über die Gruppen aktualisieren
|
||||
$slideIds = SongSlide::whereIn(
|
||||
'song_group_id',
|
||||
$song->groups()->pluck('id')
|
||||
)->pluck('id');
|
||||
$labelIds = $song->arrangements()
|
||||
->with('arrangementLabels')
|
||||
->get()
|
||||
->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
SongSlide::whereIn('id', $slideIds)->update([
|
||||
if ($labelIds->isNotEmpty()) {
|
||||
SongSlide::whereIn('label_id', $labelIds)->update([
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$song->update(['has_translation' => false]);
|
||||
}
|
||||
|
|
|
|||
20
app/Support/MacroColorConverter.php
Normal file
20
app/Support/MacroColorConverter.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class MacroColorConverter
|
||||
{
|
||||
public static function fromRgba(?array $rgba): ?string
|
||||
{
|
||||
if ($rgba === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'#%02X%02X%02X',
|
||||
(int) round(max(0.0, min(1.0, $rgba['r'])) * 255),
|
||||
(int) round(max(0.0, min(1.0, $rgba['g'])) * 255),
|
||||
(int) round(max(0.0, min(1.0, $rgba['b'])) * 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"5pm-hdh/churchtools-api": "^2.1",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
|
|
@ -33,7 +33,6 @@
|
|||
"laravel/breeze": "^2.3",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.4",
|
||||
|
|
|
|||
158
composer.lock
generated
158
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9dbae3f5dca103e9a9aab8a5291791dd",
|
||||
"content-hash": "87837501106e784aa10ddd7743056cba",
|
||||
"packages": [
|
||||
{
|
||||
"name": "5pm-hdh/churchtools-api",
|
||||
|
|
@ -893,12 +893,12 @@
|
|||
"version": "v7.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/firebase/php-jwt.git",
|
||||
"url": "https://github.com/googleapis/php-jwt.git",
|
||||
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
|
||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
|
||||
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
|
||||
"shasum": ""
|
||||
},
|
||||
|
|
@ -946,8 +946,8 @@
|
|||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/firebase/php-jwt/issues",
|
||||
"source": "https://github.com/firebase/php-jwt/tree/v7.0.3"
|
||||
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.3"
|
||||
},
|
||||
"time": "2026-02-25T22:16:40+00:00"
|
||||
},
|
||||
|
|
@ -3819,7 +3819,7 @@
|
|||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git",
|
||||
"reference": "22ba4aff7d29683297c0397e1bbc3699dc35ac03"
|
||||
"reference": "9e3e719806d8db3941444b8424fdd56b3b534aa8"
|
||||
},
|
||||
"require": {
|
||||
"google/protobuf": "^4.0",
|
||||
|
|
@ -3838,7 +3838,7 @@
|
|||
}
|
||||
},
|
||||
"description": "ProPresenter song file parser",
|
||||
"time": "2026-03-30T11:26:29+00:00"
|
||||
"time": "2026-05-03T19:40:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
|
|
@ -8244,69 +8244,6 @@
|
|||
},
|
||||
"time": "2026-02-10T20:00:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.53.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sail.git",
|
||||
"reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb",
|
||||
"reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.0",
|
||||
"symfony/console": "^6.0|^7.0|^8.0",
|
||||
"symfony/yaml": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
|
||||
"phpstan/phpstan": "^2.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/sail"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sail\\SailServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sail\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Docker files for running a basic Laravel application.",
|
||||
"keywords": [
|
||||
"docker",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/sail/issues",
|
||||
"source": "https://github.com/laravel/sail"
|
||||
},
|
||||
"time": "2026-02-06T12:16:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mockery/mockery",
|
||||
"version": "1.6.12",
|
||||
|
|
@ -10750,81 +10687,6 @@
|
|||
],
|
||||
"time": "2024-10-20T05:08:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v8.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0",
|
||||
"reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-ctype": "^1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^7.4|^8.0"
|
||||
},
|
||||
"bin": [
|
||||
"Resources/bin/yaml-lint"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Yaml\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v8.0.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-09T10:14:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ta-tikoma/phpunit-architecture-test",
|
||||
"version": "0.8.7",
|
||||
|
|
@ -11005,8 +10867,8 @@
|
|||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
"php": "^8.4"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.2.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
|
|
|||
21
database/factories/LabelFactory.php
Normal file
21
database/factories/LabelFactory.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Label;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class LabelFactory extends Factory
|
||||
{
|
||||
protected $model = Label::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->words(2, true),
|
||||
'color' => sprintf('#%06X', mt_rand(0, 0xFFFFFF)),
|
||||
'hidden_at' => null,
|
||||
'last_imported_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
27
database/factories/MacroFactory.php
Normal file
27
database/factories/MacroFactory.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Macro;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MacroFactory extends Factory
|
||||
{
|
||||
protected $model = Macro::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => strtoupper(Str::uuid()->toString()),
|
||||
'name' => $this->faker->words(3, true),
|
||||
'color' => sprintf('#%06X', mt_rand(0, 0xFFFFFF)),
|
||||
'trigger_on_startup' => false,
|
||||
'image_type' => 0,
|
||||
'action_count' => 0,
|
||||
'hidden_at' => null,
|
||||
'last_imported_at' => null,
|
||||
'last_imported_filename' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
|
||||
class ServiceAgendaItemFactory extends Factory
|
||||
{
|
||||
private static int $nextSortOrder = 1;
|
||||
|
||||
protected $model = ServiceAgendaItem::class;
|
||||
|
||||
public function definition(): array
|
||||
|
|
@ -28,7 +30,7 @@ public function definition(): array
|
|||
$this->faker->numberBetween(1, 2)
|
||||
),
|
||||
'service_song_id' => null,
|
||||
'sort_order' => $this->faker->numberBetween(1, 20),
|
||||
'sort_order' => self::$nextSortOrder++,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class SongArrangementGroupFactory extends Factory
|
||||
{
|
||||
protected $model = SongArrangementGroup::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'song_arrangement_id' => SongArrangement::factory(),
|
||||
'song_group_id' => SongGroup::factory(),
|
||||
'order' => $this->faker->numberBetween(1, 12),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
database/factories/SongArrangementLabelFactory.php
Normal file
22
database/factories/SongArrangementLabelFactory.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class SongArrangementLabelFactory extends Factory
|
||||
{
|
||||
protected $model = SongArrangementLabel::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'song_arrangement_id' => SongArrangement::factory(),
|
||||
'label_id' => Label::factory(),
|
||||
'order' => $this->faker->numberBetween(0, 10),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongGroup;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class SongGroupFactory extends Factory
|
||||
{
|
||||
protected $model = SongGroup::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'song_id' => Song::factory(),
|
||||
'name' => $this->faker->randomElement(['Verse 1', 'Verse 2', 'Chorus', 'Bridge']),
|
||||
'color' => $this->faker->hexColor(),
|
||||
'order' => $this->faker->numberBetween(1, 10),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\Label;
|
||||
use App\Models\SongSlide;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ class SongSlideFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'song_group_id' => SongGroup::factory(),
|
||||
'label_id' => Label::factory(),
|
||||
'order' => $this->faker->numberBetween(1, 12),
|
||||
'text_content' => implode("\n", $this->faker->sentences(3)),
|
||||
'text_content_translated' => $this->faker->optional()->sentence(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('labels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('color', 7)->nullable();
|
||||
$table->timestamp('hidden_at')->nullable();
|
||||
$table->timestamp('last_imported_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('labels');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('macros', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid', 36)->unique();
|
||||
$table->string('name');
|
||||
$table->string('color', 7)->nullable();
|
||||
$table->boolean('trigger_on_startup')->default(false);
|
||||
$table->unsignedSmallInteger('image_type')->default(0);
|
||||
$table->unsignedInteger('action_count')->default(0);
|
||||
$table->timestamp('hidden_at')->nullable();
|
||||
$table->timestamp('last_imported_at')->nullable();
|
||||
$table->string('last_imported_filename')->nullable();
|
||||
$table->timestamps();
|
||||
$table->index('hidden_at');
|
||||
});
|
||||
|
||||
Schema::create('macro_collections', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid', 36)->unique();
|
||||
$table->string('name');
|
||||
$table->timestamp('last_imported_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('macro_collection_macros', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('macro_collection_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('macro_id')->constrained('macros')->cascadeOnDelete();
|
||||
$table->unsignedInteger('order')->default(0);
|
||||
$table->timestamps();
|
||||
$table->unique(['macro_collection_id', 'macro_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('macro_collection_macros');
|
||||
Schema::dropIfExists('macro_collections');
|
||||
Schema::dropIfExists('macros');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('macro_assignments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->enum('part_type', ['information', 'moderation', 'sermon', 'song', 'agenda_item']);
|
||||
$table->foreignId('macro_id')->constrained('macros')->restrictOnDelete();
|
||||
$table->enum('position', ['all_slides', 'first_slide', 'last_slide', 'by_label']);
|
||||
$table->foreignId('label_id')->nullable()->constrained('labels')->restrictOnDelete();
|
||||
$table->unsignedInteger('order')->default(0);
|
||||
$table->timestamps();
|
||||
$table->index(['part_type', 'order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('macro_assignments');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_macro_overrides', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
|
||||
$table->enum('part_type', ['information', 'moderation', 'sermon', 'song', 'agenda_item']);
|
||||
$table->timestamps();
|
||||
$table->unique(['service_id', 'part_type']);
|
||||
});
|
||||
|
||||
Schema::create('service_macro_assignments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
|
||||
$table->enum('part_type', ['information', 'moderation', 'sermon', 'song', 'agenda_item']);
|
||||
$table->foreignId('macro_id')->constrained('macros')->restrictOnDelete();
|
||||
$table->enum('position', ['all_slides', 'first_slide', 'last_slide', 'by_label']);
|
||||
$table->foreignId('label_id')->nullable()->constrained('labels')->restrictOnDelete();
|
||||
$table->unsignedInteger('order')->default(0);
|
||||
$table->timestamps();
|
||||
$table->index(['service_id', 'part_type', 'order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('service_macro_assignments');
|
||||
Schema::dropIfExists('service_macro_overrides');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('song_groups')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DB::table('song_groups')->count() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
DB::table('song_arrangement_groups')->delete();
|
||||
DB::table('song_arrangements')->delete();
|
||||
DB::table('song_slides')->delete();
|
||||
DB::table('song_groups')->delete();
|
||||
DB::table('service_songs')->delete();
|
||||
DB::table('songs')->delete();
|
||||
} finally {
|
||||
DB::statement('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Data is unrecoverable — intentional no-op
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('song_slides', function (Blueprint $table) {
|
||||
$table->dropUnique(['song_group_id', 'order']);
|
||||
$table->dropIndex(['song_group_id']);
|
||||
$table->dropForeign(['song_group_id']);
|
||||
$table->dropColumn('song_group_id');
|
||||
});
|
||||
|
||||
Schema::table('song_slides', function (Blueprint $table) {
|
||||
$table->foreignId('label_id')->nullable()->constrained('labels')->restrictOnDelete();
|
||||
});
|
||||
|
||||
Schema::dropIfExists('song_arrangement_groups');
|
||||
Schema::dropIfExists('song_groups');
|
||||
|
||||
Schema::create('song_arrangement_labels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('song_arrangement_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('label_id')->constrained('labels')->restrictOnDelete();
|
||||
$table->unsignedInteger('order');
|
||||
$table->timestamps();
|
||||
$table->index(['song_arrangement_id', 'order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException('Destructive migration: rollback not supported. Restore from backup.');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$name = DB::table('settings')->where('key', 'macro_name')->value('value');
|
||||
$uuid = DB::table('settings')->where('key', 'macro_uuid')->value('value');
|
||||
|
||||
if (empty($name) || empty($uuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collectionName = DB::table('settings')->where('key', 'macro_collection_name')->value('value');
|
||||
$collectionUuid = DB::table('settings')->where('key', 'macro_collection_uuid')->value('value');
|
||||
|
||||
DB::transaction(function () use ($name, $uuid, $collectionName, $collectionUuid) {
|
||||
$labelId = DB::table('labels')->insertGetId([
|
||||
'name' => 'Copyright',
|
||||
'color' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$macroId = DB::table('macros')->insertGetId([
|
||||
'uuid' => strtoupper($uuid),
|
||||
'name' => $name,
|
||||
'last_imported_filename' => 'legacy-settings-migration',
|
||||
'last_imported_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if (! empty($collectionUuid) && ! empty($collectionName)) {
|
||||
$collectionId = DB::table('macro_collections')->insertGetId([
|
||||
'uuid' => strtoupper($collectionUuid),
|
||||
'name' => $collectionName,
|
||||
'last_imported_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('macro_collection_macros')->insert([
|
||||
'macro_collection_id' => $collectionId,
|
||||
'macro_id' => $macroId,
|
||||
'order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('macro_assignments')->insert([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macroId,
|
||||
'position' => 'by_label',
|
||||
'label_id' => $labelId,
|
||||
'order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('settings')
|
||||
->whereIn('key', ['macro_name', 'macro_uuid', 'macro_collection_name', 'macro_collection_uuid'])
|
||||
->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {}
|
||||
};
|
||||
1018
package-lock.json
generated
1018
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"name": "pp-planer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ export default defineConfig({
|
|||
timeout: 5000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://pp-planer.test',
|
||||
baseURL: 'https://pp-planer.ddev.site',
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
|
|
|
|||
|
|
@ -67,7 +67,16 @@ watch(
|
|||
watch(
|
||||
selectedArrangement,
|
||||
(arrangement) => {
|
||||
arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
|
||||
if (arrangement?.groups) {
|
||||
arrangementGroups.value = arrangement.groups.map((group) => ({ ...group }))
|
||||
} else if (arrangement?.arrangement_groups) {
|
||||
arrangementGroups.value = arrangement.arrangement_groups
|
||||
.map((ag) => props.availableGroups.find((g) => g.id === ag.label_id))
|
||||
.filter(Boolean)
|
||||
.map((g) => ({ ...g }))
|
||||
} else {
|
||||
arrangementGroups.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
|
@ -157,7 +166,7 @@ function saveArrangement() {
|
|||
`/arrangements/${selectedArrangement.value.id}`,
|
||||
{
|
||||
groups: arrangementGroups.value.map((group, index) => ({
|
||||
song_group_id: group.id,
|
||||
label_id: group.id,
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -170,8 +170,11 @@ watch(
|
|||
return
|
||||
}
|
||||
const arr = localArrangements.value.find((a) => a.id === Number(id))
|
||||
if (arr?.groups?.length) {
|
||||
arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
|
||||
const arrGroups = arr?.groups ?? (arr?.arrangement_groups
|
||||
? arr.arrangement_groups.map((ag) => props.availableGroups.find((g) => g.id === ag.label_id)).filter(Boolean)
|
||||
: [])
|
||||
if (arrGroups.length) {
|
||||
arrangementGroups.value = arrGroups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
|
||||
} else {
|
||||
// Fallback: show all available groups in order (Master)
|
||||
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, slides: g.slides ?? [], _uid: `${g.id}-master-${i}-${Date.now()}` }))
|
||||
|
|
@ -323,7 +326,7 @@ function saveArrangement() {
|
|||
`/arrangements/${currentArrangement.value.id}`,
|
||||
{
|
||||
groups: arrangementGroups.value.map((group, index) => ({
|
||||
song_group_id: group.id,
|
||||
label_id: group.id,
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
|
|
|
|||
90
resources/js/Components/LabelPicker.vue
Normal file
90
resources/js/Components/LabelPicker.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const model = defineModel({ type: Number, default: null })
|
||||
const search = ref('')
|
||||
const isOpen = ref(false)
|
||||
|
||||
const filtered = computed(() =>
|
||||
props.labels.filter((l) => l.name.toLowerCase().includes(search.value.toLowerCase())),
|
||||
)
|
||||
|
||||
const selected = computed(() => props.labels.find((l) => l.id === model.value))
|
||||
|
||||
function select(label) {
|
||||
model.value = label.id
|
||||
search.value = ''
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!props.disabled) isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
setTimeout(() => {
|
||||
isOpen.value = false
|
||||
}, 150)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm"
|
||||
:class="{ 'cursor-not-allowed opacity-50': disabled }"
|
||||
@click="open"
|
||||
data-testid="label-picker-trigger"
|
||||
>
|
||||
<span
|
||||
v-if="selected?.color"
|
||||
class="h-4 w-4 shrink-0 rounded"
|
||||
:style="{ backgroundColor: selected.color }"
|
||||
/>
|
||||
<span class="flex-1 truncate text-gray-700">
|
||||
{{ selected ? selected.name : 'Label auswählen...' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
data-testid="label-picker-dropdown"
|
||||
>
|
||||
<div class="border-b border-gray-100 p-2">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Label suchen..."
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
autofocus
|
||||
@blur="close"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<button
|
||||
v-for="label in filtered"
|
||||
:key="label.id"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-amber-50"
|
||||
:class="label.hidden_at ? 'text-gray-400' : 'text-gray-700'"
|
||||
:data-testid="'label-option-' + label.id"
|
||||
@click="select(label)"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 shrink-0 rounded"
|
||||
:style="label.color ? { backgroundColor: label.color } : { backgroundColor: '#ccc' }"
|
||||
/>
|
||||
<span class="truncate">{{ label.name }}{{ label.hidden_at ? ' (deaktiviert)' : '' }}</span>
|
||||
</button>
|
||||
<div v-if="filtered.length === 0" class="px-3 py-4 text-center text-sm text-gray-400">
|
||||
Kein Label gefunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
28
resources/js/Components/MacroIcon.vue
Normal file
28
resources/js/Components/MacroIcon.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
count: { type: Number, default: 0 },
|
||||
hasWarning: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="count > 0"
|
||||
data-testid="macro-icon"
|
||||
class="relative flex items-center justify-center rounded-lg bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200"
|
||||
:aria-label="`${count} Makro-Zuweisungen`"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="ml-1">{{ count }}</span>
|
||||
<span
|
||||
v-if="hasWarning"
|
||||
class="absolute -right-1 -top-1 flex h-3 w-3 items-center justify-center rounded-full bg-red-500"
|
||||
aria-label="Warnung: deaktiviertes Makro"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
116
resources/js/Components/MacroPicker.vue
Normal file
116
resources/js/Components/MacroPicker.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
macros: { type: Array, default: () => [] },
|
||||
collections: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const model = defineModel({ type: Number, default: null })
|
||||
const search = ref('')
|
||||
const isOpen = ref(false)
|
||||
|
||||
const filteredMacros = computed(() => {
|
||||
const q = search.value.toLowerCase()
|
||||
return props.macros.filter((m) => m.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const groupedMacros = computed(() => {
|
||||
const groups = {}
|
||||
props.collections.forEach((c) => {
|
||||
groups[c.name] = []
|
||||
})
|
||||
groups['Ohne Sammlung'] = []
|
||||
filteredMacros.value.forEach((m) => {
|
||||
const coll = props.collections.find((c) => c.macros?.some((cm) => cm.id === m.id))
|
||||
const key = coll?.name ?? 'Ohne Sammlung'
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(m)
|
||||
})
|
||||
return groups
|
||||
})
|
||||
|
||||
const selectedMacro = computed(() => props.macros.find((m) => m.id === model.value))
|
||||
|
||||
function select(macro) {
|
||||
model.value = macro.id
|
||||
search.value = ''
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!props.disabled) isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
setTimeout(() => {
|
||||
isOpen.value = false
|
||||
}, 150)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm"
|
||||
:class="{ 'cursor-not-allowed opacity-50': disabled }"
|
||||
@click="open"
|
||||
data-testid="macro-picker-trigger"
|
||||
>
|
||||
<span
|
||||
v-if="selectedMacro?.color"
|
||||
class="h-4 w-4 shrink-0 rounded"
|
||||
:style="{ backgroundColor: selectedMacro.color }"
|
||||
/>
|
||||
<span class="flex-1 truncate text-gray-700">
|
||||
{{ selectedMacro ? selectedMacro.name : 'Makro auswählen...' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
data-testid="macro-picker-dropdown"
|
||||
>
|
||||
<div class="border-b border-gray-100 p-2">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Makro suchen..."
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
data-testid="macro-picker-search"
|
||||
autofocus
|
||||
@blur="close"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<template v-for="(group, name) in groupedMacros" :key="name">
|
||||
<div
|
||||
v-if="group.length > 0"
|
||||
class="bg-gray-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
<button
|
||||
v-for="macro in group"
|
||||
:key="macro.id"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-amber-50"
|
||||
:class="macro.hidden_at ? 'text-gray-400' : 'text-gray-700'"
|
||||
:data-testid="'macro-option-' + macro.id"
|
||||
@click="select(macro)"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 shrink-0 rounded"
|
||||
:style="macro.color ? { backgroundColor: macro.color } : { backgroundColor: '#ccc' }"
|
||||
/>
|
||||
<span class="truncate">{{ macro.name }}{{ macro.hidden_at ? ' (deaktiviert)' : '' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="filteredMacros.length === 0" class="px-3 py-4 text-center text-sm text-gray-400">
|
||||
Kein Makro gefunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
145
resources/js/Components/ServicePartMacroPanel.vue
Normal file
145
resources/js/Components/ServicePartMacroPanel.vue
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
serviceId: { type: Number, required: true },
|
||||
partType: { type: String, required: true },
|
||||
partLabel: { type: String, required: true },
|
||||
isOverridden: { type: Boolean, default: false },
|
||||
assignments: { type: Array, default: () => [] },
|
||||
hasWarning: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const busy = ref(false)
|
||||
|
||||
const positionLabels = {
|
||||
all_slides: 'Alle Folien',
|
||||
first_slide: 'Erste Folie',
|
||||
last_slide: 'Letzte Folie',
|
||||
by_label: 'Nach Label',
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '')
|
||||
}
|
||||
|
||||
async function anpassen() {
|
||||
busy.value = true
|
||||
try {
|
||||
await fetch(route('services.macro-overrides.store', props.serviceId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ part_type: props.partType }),
|
||||
})
|
||||
router.reload({ preserveScroll: true })
|
||||
emit('close')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revertToGlobal() {
|
||||
if (!confirm('Soll die Anpassung aufgehoben werden? Die globalen Zuweisungen werden wiederhergestellt.')) return
|
||||
busy.value = true
|
||||
try {
|
||||
await fetch(route('services.macro-overrides.destroy', props.serviceId), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ part_type: props.partType }),
|
||||
})
|
||||
router.reload({ preserveScroll: true })
|
||||
emit('close')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute right-0 top-8 z-50 w-80 rounded-xl border border-gray-200 bg-white shadow-lg"
|
||||
:data-testid="'macro-panel-' + partType"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<h4 class="text-sm font-semibold text-gray-900">Makros für {{ partLabel }}</h4>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
:data-testid="'btn-close-macro-panel-' + partType"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<!-- Override status badge -->
|
||||
<div
|
||||
v-if="isOverridden"
|
||||
class="mb-3 flex items-center gap-1.5 rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Anpassung aktiv für diesen Gottesdienst
|
||||
</div>
|
||||
<div v-else class="mb-3 text-xs text-gray-400">
|
||||
Globale Zuweisungen werden verwendet
|
||||
</div>
|
||||
|
||||
<!-- Assignments list -->
|
||||
<div v-if="assignments.length > 0" class="mb-3 space-y-1">
|
||||
<div
|
||||
v-for="a in assignments"
|
||||
:key="a.id"
|
||||
class="flex items-center gap-2 rounded-lg bg-gray-50 px-2 py-1.5 text-xs"
|
||||
:data-testid="'macro-panel-assignment-' + a.id"
|
||||
>
|
||||
<span
|
||||
v-if="a.macro_color"
|
||||
class="h-3 w-3 shrink-0 rounded"
|
||||
:style="{ backgroundColor: a.macro_color }"
|
||||
/>
|
||||
<span class="flex-1 truncate text-gray-700">{{ a.macro_name }}</span>
|
||||
<span class="text-gray-400">{{ positionLabels[a.position] }}</span>
|
||||
<span v-if="a.macro_hidden" class="rounded bg-amber-100 px-1 py-0.5 text-amber-700">⚠</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="mb-3 text-xs text-gray-400">Keine Makros zugewiesen.</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="!isOverridden"
|
||||
class="flex-1 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600 disabled:opacity-50"
|
||||
:disabled="busy"
|
||||
title="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-testid="'btn-anpassen-' + partType"
|
||||
@click="anpassen"
|
||||
>
|
||||
Anpassen
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="flex-1 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
:disabled="busy"
|
||||
:data-testid="'btn-standard-' + partType"
|
||||
@click="revertToGlobal"
|
||||
>
|
||||
Auf Standard zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -150,10 +150,10 @@ const arrangements = computed(() => {
|
|||
name: arr.name,
|
||||
is_default: arr.is_default,
|
||||
groups: arr.arrangement_groups.map((ag) => {
|
||||
const group = songData.value.groups.find((g) => g.id === ag.song_group_id)
|
||||
const group = songData.value.groups.find((g) => g.id === ag.label_id)
|
||||
|
||||
return {
|
||||
id: ag.song_group_id,
|
||||
id: ag.label_id,
|
||||
name: group?.name ?? 'Unbekannt',
|
||||
color: group?.color ?? '#6b7280',
|
||||
order: ag.order,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
|
|||
import AgendaItemRow from '@/Components/AgendaItemRow.vue'
|
||||
import SongAgendaItem from '@/Components/SongAgendaItem.vue'
|
||||
import ArrangementDialog from '@/Components/ArrangementDialog.vue'
|
||||
import MacroIcon from '@/Components/MacroIcon.vue'
|
||||
import ServicePartMacroPanel from '@/Components/ServicePartMacroPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
service: {
|
||||
|
|
@ -40,8 +42,30 @@ const props = defineProps({
|
|||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
macros_per_part: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const openMacroPanel = ref(null)
|
||||
|
||||
const macroPartLabels = {
|
||||
information: 'Informationen',
|
||||
moderation: 'Moderation',
|
||||
sermon: 'Predigt',
|
||||
song: 'Lieder',
|
||||
agenda_item: 'Ablaufpunkte',
|
||||
}
|
||||
|
||||
function toggleMacroPanel(partType) {
|
||||
openMacroPanel.value = openMacroPanel.value === partType ? null : partType
|
||||
}
|
||||
|
||||
function macroPartData(partType) {
|
||||
return props.macros_per_part?.[partType] ?? { count: 0, is_overridden: false, has_warning: false, assignments: [] }
|
||||
}
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.service.date) return ''
|
||||
return new Date(props.service.date).toLocaleDateString('de-DE', {
|
||||
|
|
@ -92,9 +116,9 @@ function getArrangements(item) {
|
|||
name: arr.name,
|
||||
is_default: arr.is_default,
|
||||
groups: (arr.arrangement_groups ?? []).map((ag) => {
|
||||
const group = song.groups?.find((g) => g.id === ag.song_group_id) ?? ag.group ?? {}
|
||||
const group = song.groups?.find((g) => g.id === ag.label_id) ?? ag.group ?? {}
|
||||
return {
|
||||
id: ag.song_group_id ?? group.id,
|
||||
id: ag.label_id ?? group.id,
|
||||
name: group.name ?? 'Unbekannt',
|
||||
color: group.color ?? '#6b7280',
|
||||
order: ag.order,
|
||||
|
|
@ -358,7 +382,31 @@ async function downloadService() {
|
|||
<!-- Ablauf (Agenda) -->
|
||||
<div class="py-6">
|
||||
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 class="text-base font-semibold text-gray-900">Ablauf</h3>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<template v-for="partType in ['agenda_item', 'moderation', 'sermon', 'song']" :key="partType">
|
||||
<div class="relative">
|
||||
<MacroIcon
|
||||
:count="macroPartData(partType).count"
|
||||
:has-warning="macroPartData(partType).has_warning"
|
||||
:data-testid="'macro-icon-' + partType"
|
||||
@click="toggleMacroPanel(partType)"
|
||||
/>
|
||||
<ServicePartMacroPanel
|
||||
v-if="openMacroPanel === partType"
|
||||
:service-id="service.id"
|
||||
:part-type="partType"
|
||||
:part-label="macroPartLabels[partType]"
|
||||
:is-overridden="macroPartData(partType).is_overridden"
|
||||
:assignments="macroPartData(partType).assignments"
|
||||
:has-warning="macroPartData(partType).has_warning"
|
||||
@close="openMacroPanel = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!agendaItems || agendaItems.length === 0"
|
||||
|
|
@ -426,10 +474,28 @@ async function downloadService() {
|
|||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
|
||||
<p class="text-xs text-gray-500">Info-Folien für alle kommenden Services</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<MacroIcon
|
||||
:count="macroPartData('information').count"
|
||||
:has-warning="macroPartData('information').has_warning"
|
||||
:data-testid="'macro-icon-information'"
|
||||
@click="toggleMacroPanel('information')"
|
||||
/>
|
||||
<ServicePartMacroPanel
|
||||
v-if="openMacroPanel === 'information'"
|
||||
:service-id="service.id"
|
||||
:part-type="'information'"
|
||||
:part-label="macroPartLabels.information"
|
||||
:is-overridden="macroPartData('information').is_overridden"
|
||||
:assignments="macroPartData('information').assignments"
|
||||
:has-warning="macroPartData('information').has_warning"
|
||||
@close="openMacroPanel = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InformationBlock
|
||||
:service-id="service.id"
|
||||
|
|
|
|||
|
|
@ -1,70 +1,41 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import AgendaSettings from './Settings/AgendaSettings.vue'
|
||||
import LabelImport from './Settings/LabelImport.vue'
|
||||
import MacroAssignments from './Settings/MacroAssignments.vue'
|
||||
import MacroImport from './Settings/MacroImport.vue'
|
||||
import { Head } from '@inertiajs/vue3'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
settings: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
settings: { type: Object, default: () => ({}) },
|
||||
assignments: { type: Array, default: () => [] },
|
||||
macros: { type: Array, default: () => [] },
|
||||
labels: { type: Array, default: () => [] },
|
||||
collections: { type: Array, default: () => [] },
|
||||
last_macros_import: { type: Object, default: () => ({}) },
|
||||
last_labels_import: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const fields = [
|
||||
// Macro configuration fields
|
||||
{ key: 'macro_name', label: 'Makro-Name', placeholder: 'z.B. Copyright Makro', section: 'macro' },
|
||||
{ key: 'macro_uuid', label: 'Makro-UUID', placeholder: 'z.B. 11111111-2222-3333-4444-555555555555', section: 'macro' },
|
||||
{ key: 'macro_collection_name', label: 'Collection-Name', defaultValue: '--MAIN--', section: 'macro' },
|
||||
{ key: 'macro_collection_uuid', label: 'Collection-UUID', defaultValue: '8D02FC57-83F8-4042-9B90-81C229728426', section: 'macro' },
|
||||
// Agenda configuration fields
|
||||
{ key: 'agenda_start_title', label: 'Ablauf-Start', placeholder: 'z.B. Ablauf* oder Beginn*', section: 'agenda' },
|
||||
{ key: 'agenda_end_title', label: 'Ablauf-Ende', placeholder: 'z.B. Ende* oder Schluss*', section: 'agenda' },
|
||||
{ key: 'agenda_announcement_position', label: 'Ankündigungen-Position', placeholder: 'z.B. Informationen*,Hinweise*', helpText: 'Komma-getrennte Liste. Das erste passende Element im Ablauf bestimmt, wo die Ankündigungsfolien eingefügt werden. * als Platzhalter.', section: 'agenda' },
|
||||
{ key: 'agenda_sermon_matching', label: 'Predigt-Erkennung', placeholder: 'z.B. Predigt*,Sermon*', helpText: 'Komma-getrennte Liste. Erkannte Elemente bekommen einen Predigt-Upload-Bereich. * als Platzhalter.', section: 'agenda' },
|
||||
const submenus = [
|
||||
{ key: 'assignments', label: 'Makro-Zuweisungen' },
|
||||
{ key: 'macros', label: 'Makro-Import' },
|
||||
{ key: 'labels', label: 'Label-Import' },
|
||||
{ key: 'agenda', label: 'Agenda' },
|
||||
]
|
||||
|
||||
const form = reactive({})
|
||||
for (const field of fields) {
|
||||
form[field.key] = props.settings[field.key] ?? field.defaultValue ?? ''
|
||||
}
|
||||
const activeSubmenu = ref('assignments')
|
||||
|
||||
const saving = reactive({})
|
||||
const saved = reactive({})
|
||||
const errors = reactive({})
|
||||
|
||||
async function saveField(key) {
|
||||
if (saving[key]) return
|
||||
|
||||
saving[key] = true
|
||||
errors[key] = null
|
||||
saved[key] = false
|
||||
|
||||
try {
|
||||
const response = await fetch(route('settings.update'), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(
|
||||
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
|
||||
),
|
||||
},
|
||||
body: JSON.stringify({ key, value: form[key] || null }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
errors[key] = data.message || 'Speichern fehlgeschlagen'
|
||||
return
|
||||
onMounted(() => {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (submenus.some((s) => s.key === hash)) {
|
||||
activeSubmenu.value = hash
|
||||
}
|
||||
})
|
||||
|
||||
saved[key] = true
|
||||
setTimeout(() => { saved[key] = false }, 2000)
|
||||
} catch {
|
||||
errors[key] = 'Netzwerkfehler beim Speichern'
|
||||
} finally {
|
||||
saving[key] = false
|
||||
}
|
||||
function switchSubmenu(key) {
|
||||
activeSubmenu.value = key
|
||||
window.location.hash = key
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -79,148 +50,78 @@ async function saveField(key) {
|
|||
</template>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">
|
||||
ProPresenter Makro-Konfiguration
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Diese Einstellungen werden beim Export auf Copyright-Folien als Makro-Aktion angewendet.
|
||||
</p>
|
||||
<div class="flex min-h-[400px]">
|
||||
<!-- Sidebar (desktop) -->
|
||||
<div class="hidden w-48 shrink-0 border-r border-gray-100 sm:block">
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
<button
|
||||
v-for="item in submenus"
|
||||
:key="item.key"
|
||||
:data-testid="'settings-submenu-' + item.key"
|
||||
@click="switchSubmenu(item.key)"
|
||||
:class="[
|
||||
'w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',
|
||||
activeSubmenu === item.key
|
||||
? 'border-l-2 border-amber-500 bg-amber-50 text-amber-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
|
||||
]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100 px-6">
|
||||
<div
|
||||
v-for="field in fields.filter(f => f.section === 'macro')"
|
||||
:key="field.key"
|
||||
class="py-5"
|
||||
<!-- Mobile tab bar -->
|
||||
<div class="w-full border-b border-gray-100 sm:hidden">
|
||||
<nav class="flex overflow-x-auto">
|
||||
<button
|
||||
v-for="item in submenus"
|
||||
:key="item.key"
|
||||
:data-testid="'settings-submenu-' + item.key"
|
||||
@click="switchSubmenu(item.key)"
|
||||
:class="[
|
||||
'shrink-0 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors',
|
||||
activeSubmenu === item.key
|
||||
? 'border-amber-500 text-amber-700'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900',
|
||||
]"
|
||||
>
|
||||
<label
|
||||
:for="'setting-' + field.key"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-1.5">
|
||||
<input
|
||||
:id="'setting-' + field.key"
|
||||
:data-testid="'setting-' + field.key"
|
||||
v-model="form[field.key]"
|
||||
type="text"
|
||||
:placeholder="field.placeholder || field.defaultValue || ''"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
|
||||
:class="{
|
||||
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
|
||||
'border-emerald-300': saved[field.key],
|
||||
}"
|
||||
@blur="saveField(field.key)"
|
||||
<!-- Content panel -->
|
||||
<div class="flex-1 p-6" data-testid="settings-active-panel">
|
||||
<MacroAssignments
|
||||
v-if="activeSubmenu === 'assignments'"
|
||||
:assignments="assignments"
|
||||
:macros="macros"
|
||||
:labels="labels"
|
||||
:collections="collections"
|
||||
@switch-submenu="switchSubmenu"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="saving[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="saved[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="errors[field.key]"
|
||||
class="mt-1.5 text-xs text-red-600"
|
||||
:data-testid="'error-' + field.key"
|
||||
>
|
||||
{{ errors[field.key] }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="field.defaultValue"
|
||||
class="mt-1.5 text-xs text-gray-400"
|
||||
>
|
||||
Standard: {{ field.defaultValue }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm mt-6">
|
||||
<div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Agenda-Konfiguration</h3>
|
||||
<p class="mt-1 text-xs text-gray-500">Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 px-6">
|
||||
<div v-for="field in fields.filter(f => f.section === 'agenda')" :key="field.key" class="py-5">
|
||||
<label
|
||||
:for="'setting-' + field.key"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{{ field.label }}
|
||||
</label>
|
||||
|
||||
<div class="relative mt-1.5">
|
||||
<input
|
||||
:id="'setting-' + field.key"
|
||||
:data-testid="'setting-' + field.key"
|
||||
v-model="form[field.key]"
|
||||
type="text"
|
||||
:placeholder="field.placeholder || field.defaultValue || ''"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
|
||||
:class="{
|
||||
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
|
||||
'border-emerald-300': saved[field.key],
|
||||
}"
|
||||
@blur="saveField(field.key)"
|
||||
<MacroImport
|
||||
v-if="activeSubmenu === 'macros'"
|
||||
:macros="macros"
|
||||
:collections="collections"
|
||||
:last_macros_import="last_macros_import"
|
||||
@switch-submenu="switchSubmenu"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="saving[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<LabelImport
|
||||
v-if="activeSubmenu === 'labels'"
|
||||
:labels="labels"
|
||||
:last_labels_import="last_labels_import"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="saved[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="errors[field.key]"
|
||||
class="mt-1.5 text-xs text-red-600"
|
||||
:data-testid="'error-' + field.key"
|
||||
>
|
||||
{{ errors[field.key] }}
|
||||
</p>
|
||||
|
||||
<p v-if="field.helpText" class="mt-1.5 text-xs text-gray-400">{{ field.helpText }}</p>
|
||||
|
||||
<p
|
||||
v-if="field.defaultValue"
|
||||
class="mt-1.5 text-xs text-gray-400"
|
||||
>
|
||||
Standard: {{ field.defaultValue }}
|
||||
</p>
|
||||
<AgendaSettings
|
||||
v-if="activeSubmenu === 'agenda'"
|
||||
:settings="settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
139
resources/js/Pages/Settings/AgendaSettings.vue
Normal file
139
resources/js/Pages/Settings/AgendaSettings.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
import { route } from 'ziggy-js'
|
||||
|
||||
const props = defineProps({
|
||||
settings: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const fields = [
|
||||
{ key: 'agenda_start_title', label: 'Ablauf-Start', placeholder: 'z.B. Ablauf* oder Beginn*' },
|
||||
{ key: 'agenda_end_title', label: 'Ablauf-Ende', placeholder: 'z.B. Ende* oder Schluss*' },
|
||||
{
|
||||
key: 'agenda_announcement_position',
|
||||
label: 'Ankündigungen-Position',
|
||||
placeholder: 'z.B. Informationen*,Hinweise*',
|
||||
helpText: 'Komma-getrennte Liste. Das erste passende Element im Ablauf bestimmt, wo die Ankündigungsfolien eingefügt werden. * als Platzhalter.',
|
||||
},
|
||||
{
|
||||
key: 'agenda_sermon_matching',
|
||||
label: 'Predigt-Erkennung',
|
||||
placeholder: 'z.B. Predigt*,Sermon*',
|
||||
helpText: 'Komma-getrennte Liste. Erkannte Elemente bekommen einen Predigt-Upload-Bereich. * als Platzhalter.',
|
||||
},
|
||||
]
|
||||
|
||||
const form = reactive({})
|
||||
for (const field of fields) {
|
||||
form[field.key] = props.settings[field.key] ?? ''
|
||||
}
|
||||
|
||||
const saving = reactive({})
|
||||
const saved = reactive({})
|
||||
const errors = reactive({})
|
||||
|
||||
async function saveField(key) {
|
||||
if (saving[key]) return
|
||||
saving[key] = true
|
||||
errors[key] = null
|
||||
saved[key] = false
|
||||
try {
|
||||
const response = await fetch(route('settings.update'), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||
},
|
||||
body: JSON.stringify({ key, value: form[key] || null }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
errors[key] = data.message || 'Speichern fehlgeschlagen'
|
||||
return
|
||||
}
|
||||
saved[key] = true
|
||||
setTimeout(() => { saved[key] = false }, 2000)
|
||||
} catch {
|
||||
errors[key] = 'Netzwerkfehler beim Speichern'
|
||||
} finally {
|
||||
saving[key] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">
|
||||
Agenda-Konfiguration
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.
|
||||
</p>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="py-4"
|
||||
>
|
||||
<label
|
||||
:for="'setting-' + field.key"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{{ field.label }}
|
||||
</label>
|
||||
|
||||
<div class="relative mt-1.5">
|
||||
<input
|
||||
:id="'setting-' + field.key"
|
||||
:data-testid="'setting-' + field.key"
|
||||
v-model="form[field.key]"
|
||||
type="text"
|
||||
:placeholder="field.placeholder || ''"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
|
||||
:class="{
|
||||
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
|
||||
'border-emerald-300': saved[field.key],
|
||||
}"
|
||||
@blur="saveField(field.key)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="saving[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="saved[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="errors[field.key]"
|
||||
class="mt-1.5 text-xs text-red-600"
|
||||
:data-testid="'error-' + field.key"
|
||||
>
|
||||
{{ errors[field.key] }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="field.helpText"
|
||||
class="mt-1.5 text-xs text-gray-400"
|
||||
>
|
||||
{{ field.helpText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
138
resources/js/Pages/Settings/LabelImport.vue
Normal file
138
resources/js/Pages/Settings/LabelImport.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { route } from 'ziggy-js'
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, default: () => [] },
|
||||
last_labels_import: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const uploading = ref(false)
|
||||
const result = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
result.value = null
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(route('settings.labels.import'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.message || 'Import fehlgeschlagen'
|
||||
return
|
||||
}
|
||||
result.value = await res.json()
|
||||
event.target.value = ''
|
||||
window.location.reload()
|
||||
} catch {
|
||||
error.value = 'Netzwerkfehler beim Upload'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Label-Import</h3>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-500">
|
||||
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
|
||||
<span class="group relative inline-block">
|
||||
<svg
|
||||
class="ml-1 inline h-3.5 w-3.5 cursor-help text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="absolute bottom-full left-0 z-10 mb-2 hidden w-72 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg group-hover:block">
|
||||
<strong>macOS:</strong> ~/Library/Application Support/RenewedVision/ProPresenter/Configuration/Labels<br><br>
|
||||
<strong>Windows:</strong> %APPDATA%\RenewedVision\ProPresenter\Configuration\Labels
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div v-if="last_labels_import?.at" class="mb-4 text-xs text-gray-400">
|
||||
Letzter Import: {{ last_labels_import.at }}
|
||||
<span v-if="last_labels_import.filename">({{ last_labels_import.filename }})</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
|
||||
data-testid="labels-upload-area"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ uploading ? 'Wird importiert...' : 'Labels-Datei auswählen oder hierher ziehen' }}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
data-testid="labels-file-input"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700"
|
||||
data-testid="labels-import-error"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-700"
|
||||
data-testid="labels-import-summary"
|
||||
>
|
||||
<strong>Import abgeschlossen:</strong>
|
||||
{{ result.new }} neue Labels importiert, {{ result.updated }} bestehende Labels aktualisiert.
|
||||
Insgesamt {{ result.total }} Labels in Datei.
|
||||
</div>
|
||||
|
||||
<div v-if="sortedLabels.length > 0">
|
||||
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Label-Bibliothek ({{ sortedLabels.length }})
|
||||
</h4>
|
||||
<div class="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||
<div
|
||||
v-for="label in sortedLabels"
|
||||
:key="label.id"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
:data-testid="'labels-registry-row-' + label.name"
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 shrink-0 rounded border border-gray-200"
|
||||
:style="label.color ? { backgroundColor: label.color } : { backgroundColor: '#e5e7eb' }"
|
||||
/>
|
||||
<span class="flex-1 text-gray-700">{{ label.name }}</span>
|
||||
<span v-if="label.color" class="font-mono text-xs text-gray-400">{{ label.color }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-400">Noch keine Labels importiert.</p>
|
||||
</div>
|
||||
</template>
|
||||
210
resources/js/Pages/Settings/MacroAssignments.vue
Normal file
210
resources/js/Pages/Settings/MacroAssignments.vue
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<script setup>
|
||||
import LabelPicker from '@/Components/LabelPicker.vue'
|
||||
import MacroPicker from '@/Components/MacroPicker.vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { reactive } from 'vue'
|
||||
import { route } from 'ziggy-js'
|
||||
|
||||
const props = defineProps({
|
||||
assignments: { type: Array, default: () => [] },
|
||||
macros: { type: Array, default: () => [] },
|
||||
labels: { type: Array, default: () => [] },
|
||||
collections: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const parts = [
|
||||
{ key: 'information', label: 'Informationen' },
|
||||
{ key: 'moderation', label: 'Moderation' },
|
||||
{ key: 'sermon', label: 'Predigt' },
|
||||
{ key: 'song', label: 'Lieder' },
|
||||
{ key: 'agenda_item', label: 'Agenda-Items' },
|
||||
]
|
||||
|
||||
const positions = [
|
||||
{ key: 'all_slides', label: 'Alle Folien' },
|
||||
{ key: 'first_slide', label: 'Erste Folie' },
|
||||
{ key: 'last_slide', label: 'Letzte Folie' },
|
||||
{ key: 'by_label', label: 'Nach Label' },
|
||||
]
|
||||
|
||||
function assignmentsForPart(partKey) {
|
||||
return props.assignments.filter((a) => a.part_type === partKey)
|
||||
}
|
||||
|
||||
function positionsForPart(partKey) {
|
||||
if (partKey === 'song') return positions
|
||||
return positions.filter((p) => p.key !== 'by_label')
|
||||
}
|
||||
|
||||
const adding = reactive({})
|
||||
const newAssignment = reactive({})
|
||||
|
||||
function startAdd(partKey) {
|
||||
adding[partKey] = true
|
||||
newAssignment[partKey] = { macro_id: null, position: 'all_slides', label_id: null }
|
||||
}
|
||||
|
||||
function cancelAdd(partKey) {
|
||||
adding[partKey] = false
|
||||
}
|
||||
|
||||
async function saveAssignment(partKey) {
|
||||
const data = newAssignment[partKey]
|
||||
if (!data.macro_id) return
|
||||
|
||||
await fetch(route('settings.macro-assignments.store'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
part_type: partKey,
|
||||
macro_id: data.macro_id,
|
||||
position: data.position,
|
||||
label_id: data.position === 'by_label' ? data.label_id : null,
|
||||
order: assignmentsForPart(partKey).length,
|
||||
}),
|
||||
})
|
||||
adding[partKey] = false
|
||||
router.reload({ preserveScroll: true })
|
||||
}
|
||||
|
||||
async function deleteAssignment(id) {
|
||||
await fetch(route('settings.macro-assignments.destroy', id), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||
},
|
||||
})
|
||||
router.reload({ preserveScroll: true })
|
||||
}
|
||||
|
||||
function positionLabel(pos) {
|
||||
return positions.find((p) => p.key === pos)?.label ?? pos
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Globale Makro-Zuweisungen</h3>
|
||||
<p class="mb-4 text-xs text-gray-500">
|
||||
Diese Zuweisungen gelten für alle Gottesdienste. Pro Gottesdienst können sie überschrieben werden.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div v-for="part in parts" :key="part.key">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-gray-700">{{ part.label }}</h4>
|
||||
<span class="text-xs text-gray-400">{{ assignmentsForPart(part.key).length }} Zuweisungen</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="a in assignmentsForPart(part.key)"
|
||||
:key="a.id"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
|
||||
:data-testid="'assignment-card-' + a.id"
|
||||
>
|
||||
<span
|
||||
v-if="a.macro?.color"
|
||||
class="h-3 w-3 shrink-0 rounded"
|
||||
:style="{ backgroundColor: a.macro.color }"
|
||||
/>
|
||||
<span class="flex-1 text-gray-700">{{ a.macro?.name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ positionLabel(a.position) }}</span>
|
||||
<span
|
||||
v-if="a.position === 'by_label' && a.label"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
→ {{ a.label.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="a.position === 'by_label' && a.label?.hidden_at"
|
||||
class="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700"
|
||||
data-testid="warning-hidden-label"
|
||||
>
|
||||
⚠ Label deaktiviert
|
||||
</span>
|
||||
<span
|
||||
v-if="a.macro?.hidden_at"
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700"
|
||||
data-testid="warning-hidden-macro"
|
||||
>
|
||||
⚠ Makro deaktiviert
|
||||
</span>
|
||||
<button
|
||||
class="ml-2 text-gray-400 hover:text-red-500"
|
||||
:data-testid="'delete-assignment-' + a.id"
|
||||
@click="deleteAssignment(a.id)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="adding[part.key]"
|
||||
class="mt-2 space-y-2 rounded-lg border border-amber-200 bg-amber-50 p-3"
|
||||
>
|
||||
<MacroPicker
|
||||
v-model="newAssignment[part.key].macro_id"
|
||||
:macros="macros"
|
||||
:collections="collections"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="pos in positionsForPart(part.key)"
|
||||
:key="pos.key"
|
||||
class="flex items-center gap-1.5 text-xs text-gray-700"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:value="pos.key"
|
||||
v-model="newAssignment[part.key].position"
|
||||
class="text-amber-500"
|
||||
/>
|
||||
{{ pos.label }}
|
||||
</label>
|
||||
</div>
|
||||
<LabelPicker
|
||||
v-if="newAssignment[part.key]?.position === 'by_label'"
|
||||
v-model="newAssignment[part.key].label_id"
|
||||
:labels="labels"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600"
|
||||
:data-testid="'save-assignment-' + part.key"
|
||||
@click="saveAssignment(part.key)"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
@click="cancelAdd(part.key)"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="mt-1 flex items-center gap-1 rounded-lg border border-dashed border-gray-200 px-3 py-1.5 text-xs text-gray-500 transition-colors hover:border-amber-300 hover:text-amber-600"
|
||||
:data-testid="'add-assignment-' + part.key"
|
||||
@click="startAdd(part.key)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Zuweisung hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
181
resources/js/Pages/Settings/MacroImport.vue
Normal file
181
resources/js/Pages/Settings/MacroImport.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { route } from 'ziggy-js'
|
||||
|
||||
const props = defineProps({
|
||||
macros: { type: Array, default: () => [] },
|
||||
collections: { type: Array, default: () => [] },
|
||||
last_macros_import: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['switch-submenu'])
|
||||
|
||||
const uploading = ref(false)
|
||||
const result = ref(null)
|
||||
const error = ref(null)
|
||||
const selectedCollection = ref(null)
|
||||
|
||||
const filteredMacros = computed(() => {
|
||||
if (!selectedCollection.value) return props.macros
|
||||
const coll = props.collections.find((c) => c.id === selectedCollection.value)
|
||||
if (!coll) return props.macros
|
||||
const ids = coll.macros?.map((m) => m.id) ?? []
|
||||
return props.macros.filter((m) => ids.includes(m.id))
|
||||
})
|
||||
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
result.value = null
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(route('settings.macros.import'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.message || 'Import fehlgeschlagen'
|
||||
return
|
||||
}
|
||||
result.value = await res.json()
|
||||
event.target.value = ''
|
||||
window.location.reload()
|
||||
} catch {
|
||||
error.value = 'Netzwerkfehler beim Upload'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Makro-Import</h3>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-500">
|
||||
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
|
||||
<span class="group relative inline-block">
|
||||
<svg
|
||||
class="ml-1 inline h-3.5 w-3.5 cursor-help text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="absolute bottom-full left-0 z-10 mb-2 hidden w-72 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg group-hover:block">
|
||||
<strong>macOS:</strong> ~/Library/Application Support/RenewedVision/ProPresenter/Configuration/Macros<br><br>
|
||||
<strong>Windows:</strong> %APPDATA%\RenewedVision\ProPresenter\Configuration\Macros
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div v-if="last_macros_import?.at" class="mb-4 text-xs text-gray-400">
|
||||
Letzter Import: {{ last_macros_import.at }}
|
||||
<span v-if="last_macros_import.filename">({{ last_macros_import.filename }})</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
|
||||
data-testid="macros-upload-area"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ uploading ? 'Wird importiert...' : 'Makro-Datei auswählen oder hierher ziehen' }}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
data-testid="macros-file-input"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700"
|
||||
data-testid="macros-import-error"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="mb-4 space-y-1 rounded-lg bg-green-50 p-3 text-sm text-green-700"
|
||||
data-testid="macros-import-summary"
|
||||
>
|
||||
<p><strong>Import abgeschlossen:</strong></p>
|
||||
<p>{{ result.stats.new }} neue Makros importiert</p>
|
||||
<p>{{ result.stats.updated }} bestehende Makros aktualisiert</p>
|
||||
<p>{{ result.stats.disabled }} Makros deaktiviert (nicht mehr in Datei vorhanden)</p>
|
||||
<p v-if="result.stats.re_enabled > 0">{{ result.stats.re_enabled }} Makros wieder aktiviert</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result?.warnings?.length > 0"
|
||||
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
data-testid="macros-import-warnings"
|
||||
>
|
||||
<p class="mb-2 font-semibold">⚠ Achtung: Folgende deaktivierte Makros sind noch zugewiesen:</p>
|
||||
<ul class="space-y-1 text-xs">
|
||||
<li v-for="w in result.warnings" :key="w.macro_uuid">
|
||||
{{ w.macro_name }} (Bereich: {{ w.part_type }})
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
class="mt-2 text-xs text-amber-700 underline"
|
||||
@click="emit('switch-submenu', 'assignments')"
|
||||
>
|
||||
Zu den Makro-Zuweisungen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="macros.length > 0">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Makro-Bibliothek ({{ macros.length }})
|
||||
</h4>
|
||||
<select
|
||||
v-model="selectedCollection"
|
||||
class="ml-auto rounded border-gray-300 text-xs"
|
||||
data-testid="macros-collection-filter"
|
||||
>
|
||||
<option :value="null">Alle Sammlungen</option>
|
||||
<option v-for="c in collections" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||
<div
|
||||
v-for="macro in filteredMacros"
|
||||
:key="macro.id"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
:class="macro.hidden_at ? 'opacity-50' : ''"
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 shrink-0 rounded border border-gray-200"
|
||||
:style="macro.color ? { backgroundColor: macro.color } : { backgroundColor: '#e5e7eb' }"
|
||||
/>
|
||||
<span class="flex-1 text-gray-700">
|
||||
{{ macro.name }}{{ macro.hidden_at ? ' (deaktiviert)' : '' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ macro.action_count }} Aktionen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-400">Noch keine Makros importiert.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\LabelImportController;
|
||||
use App\Http\Controllers\MacroAssignmentController;
|
||||
use App\Http\Controllers\MacroImportController;
|
||||
use App\Http\Controllers\ServiceController;
|
||||
use App\Http\Controllers\ServiceMacroOverrideController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use App\Http\Controllers\SongPdfController;
|
||||
use App\Http\Controllers\SyncController;
|
||||
|
|
@ -90,4 +94,34 @@
|
|||
Route::post('/slides/reorder', '\\App\\Http\\Controllers\\SlideController@reorder')->name('slides.reorder');
|
||||
Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy');
|
||||
Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Makro- und Label-Import (ProPresenter)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import');
|
||||
Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Globale Makro-Zuweisungen
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::get('/settings/macro-assignments', [MacroAssignmentController::class, 'index'])->name('settings.macro-assignments.index');
|
||||
Route::post('/settings/macro-assignments/reorder', [MacroAssignmentController::class, 'reorder'])->name('settings.macro-assignments.reorder');
|
||||
Route::post('/settings/macro-assignments', [MacroAssignmentController::class, 'store'])->name('settings.macro-assignments.store');
|
||||
Route::patch('/settings/macro-assignments/{macroAssignment}', [MacroAssignmentController::class, 'update'])->name('settings.macro-assignments.update');
|
||||
Route::delete('/settings/macro-assignments/{macroAssignment}', [MacroAssignmentController::class, 'destroy'])->name('settings.macro-assignments.destroy');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Service-spezifische Makro-Overrides
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::post('/services/{service}/macro-overrides', [ServiceMacroOverrideController::class, 'store'])->name('services.macro-overrides.store');
|
||||
Route::delete('/services/{service}/macro-overrides', [ServiceMacroOverrideController::class, 'destroy'])->name('services.macro-overrides.destroy');
|
||||
Route::post('/services/{service}/macro-assignments', [ServiceMacroOverrideController::class, 'storeAssignment'])->name('services.macro-assignments.store');
|
||||
Route::patch('/services/{service}/macro-assignments/{serviceMacroAssignment}', [ServiceMacroOverrideController::class, 'updateAssignment'])->name('services.macro-assignments.update');
|
||||
Route::delete('/services/{service}/macro-assignments/{serviceMacroAssignment}', [ServiceMacroOverrideController::class, 'destroyAssignment'])->name('services.macro-assignments.destroy');
|
||||
});
|
||||
|
|
|
|||
111
start_dev.sh
111
start_dev.sh
|
|
@ -3,7 +3,8 @@ set -euo pipefail
|
|||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PID_FILE="$PROJECT_DIR/.dev.pid"
|
||||
SITE_NAME="pp-planer"
|
||||
LOG_FILE="$PROJECT_DIR/.dev.log"
|
||||
SITE_URL="https://pp-planer.ddev.site"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -12,95 +13,59 @@ RED='\033[0;31m'
|
|||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ── Stale PID cleanup ──────────────────────────────────────
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
ALL_DEAD=true
|
||||
while read -r PID; do
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
ALL_DEAD=false
|
||||
break
|
||||
fi
|
||||
done < <(tr ' ' '\n' < "$PID_FILE")
|
||||
if ! command -v ddev >/dev/null 2>&1; then
|
||||
echo -e "${RED}✗ DDEV ist nicht installiert.${NC}"
|
||||
echo ""
|
||||
echo -e " Installieren mit Homebrew:"
|
||||
echo -e " ${CYAN}brew install ddev/ddev/ddev${NC}"
|
||||
echo ""
|
||||
echo -e " Doku: ${CYAN}https://ddev.readthedocs.io/${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ALL_DEAD" = true ]; then
|
||||
echo -e "${YELLOW}▸ Verwaiste PID-Datei entfernt${NC}"
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo -e "${RED}Dev-Umgebung läuft bereits.${NC}"
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo -e "${RED}✗ Docker läuft nicht.${NC}"
|
||||
echo -e " Starte zuerst Docker Desktop / Colima / OrbStack."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
echo -e "${RED}Dev-Worker laufen bereits (PID $(cat "$PID_FILE")).${NC}"
|
||||
echo -e "Stoppe zuerst mit: ${CYAN}./stop_dev.sh${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${YELLOW}▸ Verwaiste PID-Datei entfernt${NC}"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# ── Valet link ──────────────────────────────────────────────
|
||||
echo -e "${YELLOW}▸ Valet-Link prüfen …${NC}"
|
||||
if [ ! -L "$HOME/.config/valet/Sites/$SITE_NAME" ]; then
|
||||
valet link "$SITE_NAME" 2>/dev/null
|
||||
echo -e " ${GREEN}✓${NC} Valet-Link erstellt: ${SITE_NAME}.test"
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Valet-Link vorhanden"
|
||||
fi
|
||||
echo -e "${YELLOW}▸ DDEV starten (composer install, key:generate, migrate, npm build) …${NC}"
|
||||
ddev start
|
||||
echo -e " ${GREEN}✓${NC} DDEV läuft"
|
||||
|
||||
# ── Dependencies check ──────────────────────────────────────
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo -e "${YELLOW}▸ npm install …${NC}"
|
||||
npm install --silent
|
||||
echo -e " ${GREEN}✓${NC} Node-Module installiert"
|
||||
fi
|
||||
echo -e "${YELLOW}▸ Dev-Worker im Hintergrund starten (queue + logs + vite HMR) …${NC}"
|
||||
nohup ddev dev > "$LOG_FILE" 2>&1 &
|
||||
DEV_PID=$!
|
||||
sleep 1.5
|
||||
|
||||
if [ ! -d "vendor" ]; then
|
||||
echo -e "${YELLOW}▸ composer install …${NC}"
|
||||
composer install --quiet
|
||||
echo -e " ${GREEN}✓${NC} Composer-Pakete installiert"
|
||||
fi
|
||||
|
||||
# ── APP_KEY check ───────────────────────────────────────────
|
||||
if ! grep -q '^APP_KEY=base64:' .env 2>/dev/null; then
|
||||
echo -e "${YELLOW}▸ APP_KEY generieren …${NC}"
|
||||
php artisan key:generate --quiet
|
||||
echo -e " ${GREEN}✓${NC} APP_KEY generiert"
|
||||
fi
|
||||
|
||||
# ── Migrate ─────────────────────────────────────────────────
|
||||
echo -e "${YELLOW}▸ Migrations ausführen …${NC}"
|
||||
php artisan migrate --force --quiet
|
||||
echo -e " ${GREEN}✓${NC} Datenbank aktuell"
|
||||
|
||||
# ── Queue worker ────────────────────────────────────────────
|
||||
echo -e "${YELLOW}▸ Queue-Worker starten …${NC}"
|
||||
php artisan queue:listen --tries=1 --timeout=0 > /dev/null 2>&1 &
|
||||
QUEUE_PID=$!
|
||||
sleep 0.3
|
||||
if kill -0 "$QUEUE_PID" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} Queue-Worker (PID $QUEUE_PID)"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Queue-Worker konnte nicht gestartet werden"
|
||||
if ! kill -0 "$DEV_PID" 2>/dev/null; then
|
||||
echo -e " ${RED}✗${NC} Dev-Worker konnten nicht gestartet werden"
|
||||
echo -e " Logs: ${CYAN}cat $LOG_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Vite dev server ─────────────────────────────────────────
|
||||
echo -e "${YELLOW}▸ Vite starten …${NC}"
|
||||
npx --yes vite --port 5173 > /dev/null 2>&1 &
|
||||
VITE_PID=$!
|
||||
sleep 0.5
|
||||
if kill -0 "$VITE_PID" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} Vite (PID $VITE_PID)"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Vite konnte nicht gestartet werden"
|
||||
kill "$QUEUE_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Save PIDs ───────────────────────────────────────────────
|
||||
echo "$QUEUE_PID $VITE_PID" > "$PID_FILE"
|
||||
echo "$DEV_PID" > "$PID_FILE"
|
||||
echo -e " ${GREEN}✓${NC} Dev-Worker (PID $DEV_PID)"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}═══════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Dev-Umgebung läuft!${NC}"
|
||||
echo -e "${GREEN}═══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e " App: ${CYAN}http://${SITE_NAME}.test${NC}"
|
||||
echo -e " Vite: ${CYAN}http://localhost:5173${NC}"
|
||||
echo -e " App: ${CYAN}${SITE_URL}${NC}"
|
||||
echo -e " Vite: ${CYAN}${SITE_URL}:5173${NC}"
|
||||
echo -e " Mail: ${CYAN}${SITE_URL}:8026${NC} (Mailpit)"
|
||||
echo -e " Logs: ${CYAN}tail -f .dev.log${NC}"
|
||||
echo ""
|
||||
echo -e " Stop: ${YELLOW}./stop_dev.sh${NC}"
|
||||
echo ""
|
||||
|
|
|
|||
61
stop_dev.sh
61
stop_dev.sh
|
|
@ -3,34 +3,55 @@ set -euo pipefail
|
|||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PID_FILE="$PROJECT_DIR/.dev.pid"
|
||||
LOG_FILE="$PROJECT_DIR/.dev.log"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo -e "${RED}Dev-Umgebung läuft nicht (keine PID-Datei).${NC}"
|
||||
KEEP_DDEV=false
|
||||
if [ "${1:-}" = "--keep-ddev" ]; then
|
||||
KEEP_DDEV=true
|
||||
fi
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
DEV_PID="$(cat "$PID_FILE")"
|
||||
if kill -0 "$DEV_PID" 2>/dev/null; then
|
||||
kill -TERM "$DEV_PID" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5; do
|
||||
kill -0 "$DEV_PID" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
kill -0 "$DEV_PID" 2>/dev/null && kill -KILL "$DEV_PID" 2>/dev/null || true
|
||||
echo -e " ${YELLOW}▸${NC} Dev-Worker (PID $DEV_PID) gestoppt"
|
||||
else
|
||||
echo -e " ${YELLOW}▸${NC} Dev-Worker waren bereits beendet"
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo -e " ${YELLOW}▸${NC} Keine PID-Datei – keine laufenden Dev-Worker"
|
||||
fi
|
||||
|
||||
rm -f "$LOG_FILE"
|
||||
|
||||
if [ "$KEEP_DDEV" = true ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}Dev-Worker gestoppt. DDEV läuft weiter.${NC}"
|
||||
echo -e " Komplett stoppen: ${CYAN}ddev stop${NC}"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STOPPED=0
|
||||
ALREADY_DEAD=0
|
||||
|
||||
while read -r PID; do
|
||||
if kill "$PID" 2>/dev/null; then
|
||||
STOPPED=$((STOPPED + 1))
|
||||
echo -e " ${YELLOW}▸${NC} Prozess $PID gestoppt"
|
||||
else
|
||||
ALREADY_DEAD=$((ALREADY_DEAD + 1))
|
||||
fi
|
||||
done < <(tr ' ' '\n' < "$PID_FILE")
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
|
||||
echo ""
|
||||
if [ "$ALREADY_DEAD" -gt 0 ]; then
|
||||
echo -e "${YELLOW} $ALREADY_DEAD Prozess(e) waren bereits beendet.${NC}"
|
||||
if command -v ddev >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}▸ DDEV stoppen …${NC}"
|
||||
ddev stop >/dev/null 2>&1 || true
|
||||
echo -e " ${GREEN}✓${NC} DDEV gestoppt"
|
||||
fi
|
||||
echo -e "${GREEN}Dev-Umgebung gestoppt. ($STOPPED Prozess(e) beendet)${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Dev-Umgebung komplett gestoppt.${NC}"
|
||||
echo -e " Tipp: ${CYAN}./stop_dev.sh --keep-ddev${NC} stoppt nur die Worker (DDEV bleibt an)"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Slide;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\SongSlide;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -102,8 +104,18 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
|
|||
$service = Service::factory()->create();
|
||||
|
||||
$song = Song::factory()->create(['title' => 'Amazing Grace']);
|
||||
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]);
|
||||
SongSlide::factory()->create(['song_group_id' => $group->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
|
||||
$label = Label::factory()->create(['name' => 'Verse 1']);
|
||||
SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'is_default' => true,
|
||||
]);
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$serviceSong = ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongArrangementLabel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -34,19 +34,19 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
|
|||
|
||||
$this->assertNotNull($newArrangement);
|
||||
|
||||
$defaultGroupOrder = SongGroup::query()
|
||||
->where('song_id', $song->id)
|
||||
$defaultLabelOrder = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$newGroups = SongArrangementGroup::query()
|
||||
$newLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $newArrangement->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($defaultGroupOrder, $newGroups);
|
||||
$this->assertSame($defaultLabelOrder, $newLabels);
|
||||
}
|
||||
|
||||
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void
|
||||
|
|
@ -69,19 +69,19 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups():
|
|||
$this->assertNotNull($clone);
|
||||
$this->assertFalse($clone->is_default);
|
||||
|
||||
$originalGroups = SongArrangementGroup::query()
|
||||
$originalLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$cloneGroups = SongArrangementGroup::query()
|
||||
$cloneLabels = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $clone->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($originalGroups, $cloneGroups);
|
||||
$this->assertSame($originalLabels, $cloneLabels);
|
||||
}
|
||||
|
||||
public function test_update_arrangement_reorders_and_persists_groups(): void
|
||||
|
|
@ -92,19 +92,19 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
|
|||
|
||||
$response = $this->put(route('arrangements.update', $normal), [
|
||||
'groups' => [
|
||||
['song_group_id' => $chorus->id, 'order' => 1],
|
||||
['song_group_id' => $bridge->id, 'order' => 2],
|
||||
['song_group_id' => $verse->id, 'order' => 3],
|
||||
['song_group_id' => $chorus->id, 'order' => 4],
|
||||
['label_id' => $chorus->id, 'order' => 1],
|
||||
['label_id' => $bridge->id, 'order' => 2],
|
||||
['label_id' => $verse->id, 'order' => 3],
|
||||
['label_id' => $chorus->id, 'order' => 4],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$updated = SongArrangementGroup::query()
|
||||
$updated = SongArrangementLabel::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->pluck('label_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
|
|
@ -136,23 +136,9 @@ private function createSongWithDefaultArrangement(): array
|
|||
{
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$verse = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Verse 1',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$chorus = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Chorus',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$bridge = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Bridge',
|
||||
'order' => 3,
|
||||
]);
|
||||
$verse = Label::factory()->create(['name' => 'Verse 1']);
|
||||
$chorus = Label::factory()->create(['name' => 'Chorus']);
|
||||
$bridge = Label::factory()->create(['name' => 'Bridge']);
|
||||
|
||||
$normal = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
|
|
@ -160,21 +146,21 @@ private function createSongWithDefaultArrangement(): array
|
|||
'is_default' => true,
|
||||
]);
|
||||
|
||||
SongArrangementGroup::factory()->create([
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_group_id' => $verse->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementGroup::factory()->create([
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_group_id' => $chorus->id,
|
||||
'label_id' => $chorus->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
SongArrangementGroup::factory()->create([
|
||||
SongArrangementLabel::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_group_id' => $verse->id,
|
||||
'label_id' => $verse->id,
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
use App\Models\Slide;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongSlide;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -26,13 +24,18 @@
|
|||
'failed_jobs',
|
||||
'services',
|
||||
'songs',
|
||||
'song_groups',
|
||||
'labels',
|
||||
'song_slides',
|
||||
'song_arrangements',
|
||||
'song_arrangement_groups',
|
||||
'song_arrangement_labels',
|
||||
'service_songs',
|
||||
'slides',
|
||||
'cts_sync_log',
|
||||
'macros',
|
||||
'macro_collections',
|
||||
'macro_assignments',
|
||||
'service_macro_overrides',
|
||||
'service_macro_assignments',
|
||||
];
|
||||
|
||||
foreach ($expectedTables as $table) {
|
||||
|
|
@ -40,23 +43,24 @@
|
|||
}
|
||||
});
|
||||
|
||||
test('dropped tables no longer exist', function () {
|
||||
expect(Schema::hasTable('song_groups'))->toBeFalse();
|
||||
expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('all factories create valid records', function () {
|
||||
Service::factory()->create();
|
||||
Song::factory()->create();
|
||||
SongGroup::factory()->create();
|
||||
SongSlide::factory()->create();
|
||||
SongArrangement::factory()->create();
|
||||
SongArrangementGroup::factory()->create();
|
||||
ServiceSong::factory()->create();
|
||||
Slide::factory()->create();
|
||||
CtsSyncLog::factory()->create();
|
||||
|
||||
expect(Service::count())->toBeGreaterThan(0)
|
||||
->and(Song::count())->toBeGreaterThan(0)
|
||||
->and(SongGroup::count())->toBeGreaterThan(0)
|
||||
->and(SongSlide::count())->toBeGreaterThan(0)
|
||||
->and(SongArrangement::count())->toBeGreaterThan(0)
|
||||
->and(SongArrangementGroup::count())->toBeGreaterThan(0)
|
||||
->and(ServiceSong::count())->toBeGreaterThan(0)
|
||||
->and(Slide::count())->toBeGreaterThan(0)
|
||||
->and(CtsSyncLog::count())->toBeGreaterThan(0);
|
||||
|
|
|
|||
33
tests/Feature/LabelImportControllerTest.php
Normal file
33
tests/Feature/LabelImportControllerTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('label import requires authentication', function () {
|
||||
$response = $this->post(route('settings.labels.import'), []);
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('label import returns json on valid file', function () {
|
||||
$user = User::factory()->create();
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('settings.labels.import'), [
|
||||
'file' => new UploadedFile(base_path('tests/fixtures/labels-sample.bin'), 'labels.bin', null, null, true),
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure(['new', 'updated', 'total']);
|
||||
});
|
||||
|
||||
test('label import returns 422 on invalid file', function () {
|
||||
$user = User::factory()->create();
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('settings.labels.import'), [
|
||||
'file' => UploadedFile::fake()->create('notalabels.bin', 1),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
53
tests/Feature/LabelsImportServiceTest.php
Normal file
53
tests/Feature/LabelsImportServiceTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Setting;
|
||||
use App\Services\LabelsImportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('import creates new labels from file', function () {
|
||||
$service = app(LabelsImportService::class);
|
||||
$result = $service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
|
||||
|
||||
expect($result->newCount)->toBeGreaterThanOrEqual(1);
|
||||
expect($result->updatedCount)->toBe(0);
|
||||
expect(Label::count())->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('import updates color of existing labels', function () {
|
||||
Label::create(['name' => 'Copyright', 'color' => '#000000']);
|
||||
|
||||
$service = app(LabelsImportService::class);
|
||||
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
|
||||
|
||||
$label = Label::where('name', 'Copyright')->first();
|
||||
if ($label) {
|
||||
expect($label->color)->not->toBe('#000000');
|
||||
}
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('import stores last imported metadata in settings', function () {
|
||||
$service = app(LabelsImportService::class);
|
||||
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
|
||||
|
||||
expect(Setting::get('labels_last_imported_at'))->not->toBeNull();
|
||||
expect(Setting::get('labels_last_imported_filename'))->toBe('labels-sample.bin');
|
||||
});
|
||||
|
||||
test('re-import is idempotent — no duplicates', function () {
|
||||
$service = app(LabelsImportService::class);
|
||||
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
|
||||
$countAfterFirst = Label::count();
|
||||
$service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
|
||||
|
||||
expect(Label::count())->toBe($countAfterFirst);
|
||||
});
|
||||
|
||||
test('empty label names are skipped', function () {
|
||||
$service = app(LabelsImportService::class);
|
||||
$result = $service->import(base_path('tests/fixtures/labels-sample.bin'), 'labels-sample.bin');
|
||||
expect($result->totalInFile)->toBeGreaterThan(0);
|
||||
});
|
||||
108
tests/Feature/MacroAssignmentControllerTest.php
Normal file
108
tests/Feature/MacroAssignmentControllerTest.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('store requires authentication', function () {
|
||||
$response = $this->post(route('settings.macro-assignments.store'), []);
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('store creates macro assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('settings.macro-assignments.store'), [
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
expect(MacroAssignment::count())->toBe(1);
|
||||
expect(MacroAssignment::first()->part_type)->toBe('song');
|
||||
});
|
||||
|
||||
test('store with by_label position and label_id', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
$label = Label::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('settings.macro-assignments.store'), [
|
||||
'part_type' => 'sermon',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'by_label',
|
||||
'label_id' => $label->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$assignment = MacroAssignment::first();
|
||||
expect($assignment->label_id)->toBe($label->id);
|
||||
expect($assignment->position)->toBe('by_label');
|
||||
});
|
||||
|
||||
test('update modifies existing assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
$assignment = MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->patchJson(route('settings.macro-assignments.update', $assignment), [
|
||||
'position' => 'first_slide',
|
||||
'order' => 5,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
expect($assignment->fresh()->position)->toBe('first_slide');
|
||||
expect($assignment->fresh()->order)->toBe(5);
|
||||
});
|
||||
|
||||
test('destroy deletes macro assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
$assignment = MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->deleteJson(route('settings.macro-assignments.destroy', $assignment));
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
expect(MacroAssignment::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('reorder updates order on multiple assignments', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
$a1 = MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
$a2 = MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 1]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('settings.macro-assignments.reorder'), [
|
||||
'assignments' => [
|
||||
['id' => $a1->id, 'order' => 5],
|
||||
['id' => $a2->id, 'order' => 3],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($a1->fresh()->order)->toBe(5);
|
||||
expect($a2->fresh()->order)->toBe(3);
|
||||
});
|
||||
41
tests/Feature/MacroImportControllerTest.php
Normal file
41
tests/Feature/MacroImportControllerTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('macro import requires authentication', function () {
|
||||
$response = $this->post(route('settings.macros.import'), []);
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('macro import returns json with stats on valid file', function () {
|
||||
$user = User::factory()->create();
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('settings.macros.import'), [
|
||||
'file' => new UploadedFile(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin', null, null, true),
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure(['stats' => ['new', 'updated', 'disabled', 're_enabled'], 'warnings']);
|
||||
});
|
||||
|
||||
test('macro import returns 422 on invalid file', function () {
|
||||
$user = User::factory()->create();
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('settings.macros.import'), [
|
||||
'file' => UploadedFile::fake()->create('notamacro.bin', 1),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('macro import without file returns validation error', function () {
|
||||
$user = User::factory()->create();
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('settings.macros.import'), []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
115
tests/Feature/MacroResolutionServiceTest.php
Normal file
115
tests/Feature/MacroResolutionServiceTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceMacroAssignment;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use App\Services\MacroResolutionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('returns empty when no assignments exist', function () {
|
||||
$service = Service::factory()->create();
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
expect($resolver->resolveAssignmentsForPart($service, 'song'))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns global assignments when no override', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$resolved = $resolver->resolveAssignmentsForPart($service, 'song');
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved->first()->macro->id)->toBe($macro->id);
|
||||
});
|
||||
|
||||
test('override wins over globals', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macroA = Macro::factory()->create(['name' => 'Global']);
|
||||
$macroB = Macro::factory()->create(['name' => 'Override']);
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macroA->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
ServiceMacroOverride::create(['service_id' => $service->id, 'part_type' => 'song']);
|
||||
ServiceMacroAssignment::create(['service_id' => $service->id, 'part_type' => 'song', 'macro_id' => $macroB->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$resolved = $resolver->resolveAssignmentsForPart($service, 'song');
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved->first()->macro->name)->toBe('Override');
|
||||
});
|
||||
|
||||
test('hidden macros are filtered out', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create(['hidden_at' => now()]);
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
expect($resolver->resolveAssignmentsForPart($service, 'song'))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('macrosForSlide with all_slides matches every slide', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$result = $resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]);
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
expect($result[0]['uuid'])->toBe($macro->uuid);
|
||||
});
|
||||
|
||||
test('macrosForSlide with first_slide only matches index 0', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'first_slide', 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
|
||||
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]))->toHaveCount(1);
|
||||
expect($resolver->macrosForSlide($service, 'song', ['index' => 1, 'total' => 3, 'label_id' => null]))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('macrosForSlide stacking — multiple matching assignments produce multiple macros', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macro1 = Macro::factory()->create();
|
||||
$macro2 = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro1->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro2->id, 'position' => 'first_slide', 'order' => 1]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$result = $resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => null]);
|
||||
|
||||
expect($result)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('macrosForSlide with by_label matches only matching label_id', function () {
|
||||
$service = Service::factory()->create();
|
||||
$label = Label::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'by_label', 'label_id' => $label->id, 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
|
||||
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => $label->id]))->toHaveCount(1);
|
||||
expect($resolver->macrosForSlide($service, 'song', ['index' => 0, 'total' => 3, 'label_id' => 9999]))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('countAssignmentsForPart returns correct count', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
|
||||
expect($resolver->countAssignmentsForPart($service, 'song'))->toBe(1);
|
||||
expect($resolver->countAssignmentsForPart($service, 'sermon'))->toBe(1);
|
||||
expect($resolver->countAssignmentsForPart($service, 'information'))->toBe(0);
|
||||
});
|
||||
69
tests/Feature/MacrosImportServiceTest.php
Normal file
69
tests/Feature/MacrosImportServiceTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Setting;
|
||||
use App\Services\MacrosImportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('import creates new macros from file', function () {
|
||||
$service = app(MacrosImportService::class);
|
||||
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
expect($result->new)->toBeGreaterThanOrEqual(1);
|
||||
expect($result->updated)->toBe(0);
|
||||
expect(Macro::count())->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('import stores hex color on macros', function () {
|
||||
$service = app(MacrosImportService::class);
|
||||
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
expect(Macro::whereNotNull('color')->where('color', 'like', '#%')->count())->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('import marks missing macros as hidden', function () {
|
||||
$existing = Macro::factory()->create(['uuid' => 'FAKE-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'hidden_at' => null]);
|
||||
|
||||
$service = app(MacrosImportService::class);
|
||||
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
expect($existing->fresh()->isHidden())->toBeTrue();
|
||||
expect($result->disabled)->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('import re-enables previously hidden macros that appear in file', function () {
|
||||
$service = app(MacrosImportService::class);
|
||||
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
$firstMacro = Macro::first();
|
||||
$firstMacro->update(['hidden_at' => now()]);
|
||||
|
||||
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
expect($result->reEnabled)->toBeGreaterThanOrEqual(1);
|
||||
expect($firstMacro->fresh()->isHidden())->toBeFalse();
|
||||
});
|
||||
|
||||
test('import builds warnings for disabled macros with active assignments', function () {
|
||||
$service = app(MacrosImportService::class);
|
||||
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
$hiddenMacro = Macro::factory()->create(['uuid' => 'WARN-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'hidden_at' => now()]);
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $hiddenMacro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$result = $service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
expect(count($result->warnings))->toBeGreaterThanOrEqual(1);
|
||||
$warning = collect($result->warnings)->firstWhere('macro_uuid', 'WARN-FFFF-FFFF-FFFF-FFFFFFFFFFFF');
|
||||
expect($warning)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('import stores last imported metadata in settings', function () {
|
||||
$service = app(MacrosImportService::class);
|
||||
$service->import(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin');
|
||||
|
||||
expect(Setting::get('macros_last_imported_at'))->not->toBeNull();
|
||||
expect(Setting::get('macros_last_imported_filename'))->toBe('macros.bin');
|
||||
});
|
||||
22
tests/Feature/Migrations/LabelsTableTest.php
Normal file
22
tests/Feature/Migrations/LabelsTableTest.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('labels table has expected columns', function () {
|
||||
expect(Schema::hasTable('labels'))->toBeTrue();
|
||||
expect(Schema::hasColumns('labels', ['id', 'name', 'color', 'hidden_at', 'last_imported_at', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('labels table enforces unique name', function () {
|
||||
DB::table('labels')->insert(['name' => 'Vers 1', 'color' => '#FF0080', 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
expect(fn () => DB::table('labels')->insert(['name' => 'Vers 1', 'color' => '#000000', 'created_at' => now(), 'updated_at' => now()]))
|
||||
->toThrow(\Exception::class);
|
||||
});
|
||||
|
||||
test('labels table allows nullable color', function () {
|
||||
DB::table('labels')->insert(['name' => 'TestLabel', 'color' => null, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
expect(DB::table('labels')->where('name', 'TestLabel')->value('color'))->toBeNull();
|
||||
});
|
||||
25
tests/Feature/Migrations/MacroAssignmentsTableTest.php
Normal file
25
tests/Feature/Migrations/MacroAssignmentsTableTest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('macro_assignments table has expected columns', function () {
|
||||
expect(Schema::hasTable('macro_assignments'))->toBeTrue();
|
||||
expect(Schema::hasColumns('macro_assignments', ['id', 'part_type', 'macro_id', 'position', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('macro_assignments restrictOnDelete prevents deleting referenced macro', function () {
|
||||
$macroId = DB::table('macros')->insertGetId(['uuid' => 'AABB-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
|
||||
$labelId = DB::table('labels')->insertGetId(['name' => 'Copyright', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('macro_assignments')->insert(['part_type' => 'song', 'macro_id' => $macroId, 'position' => 'by_label', 'label_id' => $labelId, 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
expect(fn () => DB::table('macros')->where('id', $macroId)->delete())
|
||||
->toThrow(\Exception::class);
|
||||
});
|
||||
|
||||
test('macro_assignments allows nullable label_id', function () {
|
||||
$macroId = DB::table('macros')->insertGetId(['uuid' => 'CCBB-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test2', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('macro_assignments')->insert(['part_type' => 'sermon', 'macro_id' => $macroId, 'position' => 'all_slides', 'label_id' => null, 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
expect(DB::table('macro_assignments')->where('macro_id', $macroId)->value('label_id'))->toBeNull();
|
||||
});
|
||||
39
tests/Feature/Migrations/MacrosTablesTest.php
Normal file
39
tests/Feature/Migrations/MacrosTablesTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('macros table has expected columns', function () {
|
||||
expect(Schema::hasTable('macros'))->toBeTrue();
|
||||
expect(Schema::hasColumns('macros', [
|
||||
'id', 'uuid', 'name', 'color', 'trigger_on_startup', 'image_type',
|
||||
'action_count', 'hidden_at', 'last_imported_at', 'last_imported_filename',
|
||||
'created_at', 'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
test('macro_collections table has expected columns', function () {
|
||||
expect(Schema::hasTable('macro_collections'))->toBeTrue();
|
||||
expect(Schema::hasColumns('macro_collections', ['id', 'uuid', 'name', 'last_imported_at', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('macro_collection_macros junction has expected columns', function () {
|
||||
expect(Schema::hasTable('macro_collection_macros'))->toBeTrue();
|
||||
expect(Schema::hasColumns('macro_collection_macros', ['id', 'macro_collection_id', 'macro_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('deleting a collection cascades to junction rows', function () {
|
||||
$collId = DB::table('macro_collections')->insertGetId(['uuid' => 'COLL-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
|
||||
$macroId = DB::table('macros')->insertGetId(['uuid' => 'AAAA-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('macro_collection_macros')->insert(['macro_collection_id' => $collId, 'macro_id' => $macroId, 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
DB::table('macro_collections')->where('id', $collId)->delete();
|
||||
|
||||
expect(DB::table('macro_collection_macros')->where('macro_collection_id', $collId)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('macros uuid is unique', function () {
|
||||
DB::table('macros')->insert(['uuid' => 'SAME-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'First', 'created_at' => now(), 'updated_at' => now()]);
|
||||
expect(fn () => DB::table('macros')->insert(['uuid' => 'SAME-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Second', 'created_at' => now(), 'updated_at' => now()]))
|
||||
->toThrow(\Exception::class);
|
||||
});
|
||||
50
tests/Feature/Migrations/MigrateLegacyMacroSettingsTest.php
Normal file
50
tests/Feature/Migrations/MigrateLegacyMacroSettingsTest.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
test('migration is no-op when macro settings are empty', function () {
|
||||
expect(DB::table('macro_assignments')->count())->toBe(0);
|
||||
expect(DB::table('macros')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('migration creates assignment when all 4 keys are present', function () {
|
||||
DB::table('settings')->insert([
|
||||
['key' => 'macro_name', 'value' => 'Copyright Makro', 'created_at' => now(), 'updated_at' => now()],
|
||||
['key' => 'macro_uuid', 'value' => 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF', 'created_at' => now(), 'updated_at' => now()],
|
||||
['key' => 'macro_collection_name', 'value' => '--MAIN--', 'created_at' => now(), 'updated_at' => now()],
|
||||
['key' => 'macro_collection_uuid', 'value' => '8D02FC57-83F8-4042-9B90-81C229728426', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$migration = require database_path('migrations/2026_05_03_100700_migrate_legacy_macro_settings.php');
|
||||
$migration->up();
|
||||
|
||||
expect(DB::table('labels')->where('name', 'Copyright')->exists())->toBeTrue();
|
||||
expect(DB::table('macros')->where('uuid', 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF')->exists())->toBeTrue();
|
||||
expect(DB::table('macro_assignments')->where('part_type', 'song')->where('position', 'by_label')->exists())->toBeTrue();
|
||||
expect(DB::table('settings')->whereIn('key', ['macro_name', 'macro_uuid', 'macro_collection_name', 'macro_collection_uuid'])->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('migration works when only name and uuid are set (no collection)', function () {
|
||||
DB::table('settings')->insert([
|
||||
['key' => 'macro_name', 'value' => 'Simple Makro', 'created_at' => now(), 'updated_at' => now()],
|
||||
['key' => 'macro_uuid', 'value' => 'BBBBBBBB-1111-2222-3333-FFFFFFFFFFFF', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$migration = require database_path('migrations/2026_05_03_100700_migrate_legacy_macro_settings.php');
|
||||
$migration->up();
|
||||
|
||||
expect(DB::table('macro_assignments')->where('part_type', 'song')->exists())->toBeTrue();
|
||||
expect(DB::table('macro_collections')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('migration is no-op when macro_uuid is empty', function () {
|
||||
DB::table('settings')->insert([
|
||||
['key' => 'macro_name', 'value' => 'Makro Ohne UUID', 'created_at' => now(), 'updated_at' => now()],
|
||||
['key' => 'macro_uuid', 'value' => '', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$migration = require database_path('migrations/2026_05_03_100700_migrate_legacy_macro_settings.php');
|
||||
$migration->up();
|
||||
|
||||
expect(DB::table('macro_assignments')->count())->toBe(0);
|
||||
});
|
||||
35
tests/Feature/Migrations/ServiceMacroOverrideTablesTest.php
Normal file
35
tests/Feature/Migrations/ServiceMacroOverrideTablesTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('service_macro_overrides table has expected columns', function () {
|
||||
expect(Schema::hasTable('service_macro_overrides'))->toBeTrue();
|
||||
expect(Schema::hasColumns('service_macro_overrides', ['id', 'service_id', 'part_type', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('service_macro_assignments table has expected columns', function () {
|
||||
expect(Schema::hasTable('service_macro_assignments'))->toBeTrue();
|
||||
expect(Schema::hasColumns('service_macro_assignments', ['id', 'service_id', 'part_type', 'macro_id', 'position', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('service_macro_overrides unique constraint on service_id and part_type', function () {
|
||||
$service = Service::factory()->create();
|
||||
DB::table('service_macro_overrides')->insert(['service_id' => $service->id, 'part_type' => 'song', 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
expect(fn () => DB::table('service_macro_overrides')->insert(['service_id' => $service->id, 'part_type' => 'song', 'created_at' => now(), 'updated_at' => now()]))
|
||||
->toThrow(\Exception::class);
|
||||
});
|
||||
|
||||
test('service delete cascades to overrides and assignments', function () {
|
||||
$service = Service::factory()->create();
|
||||
$macroId = DB::table('macros')->insertGetId(['uuid' => 'CCDD-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('service_macro_overrides')->insert(['service_id' => $service->id, 'part_type' => 'sermon', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('service_macro_assignments')->insert(['service_id' => $service->id, 'part_type' => 'sermon', 'macro_id' => $macroId, 'position' => 'all_slides', 'order' => 0, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
$service->delete();
|
||||
|
||||
expect(DB::table('service_macro_overrides')->where('service_id', $service->id)->count())->toBe(0);
|
||||
expect(DB::table('service_macro_assignments')->where('service_id', $service->id)->count())->toBe(0);
|
||||
});
|
||||
21
tests/Feature/Migrations/SongsToLabelsRefactorTest.php
Normal file
21
tests/Feature/Migrations/SongsToLabelsRefactorTest.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('song_slides has label_id column after migration', function () {
|
||||
expect(Schema::hasColumn('song_slides', 'label_id'))->toBeTrue();
|
||||
expect(Schema::hasColumn('song_slides', 'song_group_id'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('song_arrangement_groups table is dropped', function () {
|
||||
expect(Schema::hasTable('song_arrangement_groups'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('song_arrangement_labels table exists with expected columns', function () {
|
||||
expect(Schema::hasTable('song_arrangement_labels'))->toBeTrue();
|
||||
expect(Schema::hasColumns('song_arrangement_labels', ['id', 'song_arrangement_id', 'label_id', 'order', 'created_at', 'updated_at']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('song_groups table is dropped', function () {
|
||||
expect(Schema::hasTable('song_groups'))->toBeFalse();
|
||||
});
|
||||
14
tests/Feature/Migrations/WipeLegacySongDataTest.php
Normal file
14
tests/Feature/Migrations/WipeLegacySongDataTest.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('wipe migration is no-op on fresh database', function () {
|
||||
expect(DB::table('songs')->count())->toBe(0);
|
||||
expect(Schema::hasTable('songs'))->toBeTrue();
|
||||
expect(Schema::hasTable('song_groups'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('wipe migration guard: song_groups does not exist after schema refactor', function () {
|
||||
expect(Schema::hasTable('song_groups'))->toBeFalse();
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceSong;
|
||||
|
|
@ -35,15 +36,21 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
|
|||
'copyright_text' => 'Test Publisher',
|
||||
]);
|
||||
|
||||
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
|
||||
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
$verse = Label::firstOrCreate(
|
||||
['name' => 'Verse 1 - '.$title],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
|
||||
$chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
|
||||
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
$chorus = Label::firstOrCreate(
|
||||
['name' => 'Chorus - '.$title],
|
||||
['color' => '#F44336'],
|
||||
);
|
||||
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\MacroCollection;
|
||||
use App\Models\Service;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\ProExportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -22,17 +28,23 @@ private function createSongWithContent(): Song
|
|||
'publisher' => 'Test Publisher',
|
||||
]);
|
||||
|
||||
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
|
||||
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
$verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
|
||||
$verse = Label::firstOrCreate(
|
||||
['name' => 'Verse 1 - Export Test Song'],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
||||
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
|
||||
|
||||
$chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
|
||||
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
$chorus = Label::firstOrCreate(
|
||||
['name' => 'Chorus - Export Test Song'],
|
||||
['color' => '#F44336'],
|
||||
);
|
||||
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
|
||||
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]);
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
|
@ -72,7 +84,7 @@ public function test_download_pro_roundtrip_import_export(): void
|
|||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$sourcePath = base_path('../propresenter/doc/reference_samples/Test.pro');
|
||||
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
|
||||
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
|
||||
|
||||
$importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]);
|
||||
|
|
@ -82,7 +94,9 @@ public function test_download_pro_roundtrip_import_export(): void
|
|||
$song = Song::find($songId);
|
||||
|
||||
$this->assertNotNull($song);
|
||||
$this->assertGreaterThan(0, $song->groups()->count());
|
||||
|
||||
$labelCount = $song->arrangements()->withCount('arrangementLabels')->get()->sum('arrangement_labels_count');
|
||||
$this->assertGreaterThan(0, $labelCount);
|
||||
|
||||
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
|
||||
$exportResponse->assertOk();
|
||||
|
|
@ -92,51 +106,52 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 1. Import the reference .pro file
|
||||
$sourcePath = base_path('../propresenter/doc/reference_samples/Test.pro');
|
||||
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
|
||||
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
|
||||
|
||||
$importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]);
|
||||
$importResponse->assertOk();
|
||||
|
||||
$songId = $importResponse->json('songs.0.id');
|
||||
$originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId);
|
||||
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId);
|
||||
$this->assertNotNull($originalSong);
|
||||
|
||||
// Snapshot original data
|
||||
$originalGroups = $originalSong->groups->sortBy('order')->values();
|
||||
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
|
||||
$this->assertNotNull($defaultArr);
|
||||
|
||||
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
|
||||
$originalArrangements = $originalSong->arrangements;
|
||||
|
||||
// 2. Export as .pro
|
||||
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
|
||||
$exportResponse->assertOk();
|
||||
|
||||
// Save exported content to temp file — BinaryFileResponse delivers a real file
|
||||
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
|
||||
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
|
||||
$baseResponse = $exportResponse->baseResponse;
|
||||
copy($baseResponse->getFile()->getPathname(), $tempPath);
|
||||
|
||||
// 3. Re-import the exported file as a new song (different ccli to avoid upsert)
|
||||
// Use the ProPresenter parser directly to read and verify
|
||||
$reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
|
||||
@unlink($tempPath);
|
||||
|
||||
// 4. Assert song name
|
||||
$this->assertSame($originalSong->title, $reImported->getName());
|
||||
|
||||
// 5. Assert groups match (same names, same order)
|
||||
$reImportedGroups = $reImported->getGroups();
|
||||
$this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch');
|
||||
|
||||
foreach ($originalGroups as $index => $originalGroup) {
|
||||
$uniqueOriginalLabels = $originalArrangementLabels
|
||||
->map(fn ($al) => $al->label)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch');
|
||||
|
||||
foreach ($uniqueOriginalLabels as $index => $originalLabel) {
|
||||
$reImportedGroup = $reImportedGroups[$index];
|
||||
$this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
|
||||
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
|
||||
|
||||
// Assert slides within group
|
||||
$originalSlides = $originalGroup->slides->sortBy('order')->values();
|
||||
$originalSlides = $originalLabel->songSlides->sortBy('order')->values();
|
||||
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
|
||||
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'");
|
||||
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'");
|
||||
|
||||
foreach ($originalSlides as $slideIndex => $originalSlide) {
|
||||
$reImportedSlide = $reImportedSlides[$slideIndex];
|
||||
|
|
@ -144,32 +159,30 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
$this->assertSame(
|
||||
$originalSlide->text_content,
|
||||
$reImportedSlide->getPlainText(),
|
||||
"Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
|
||||
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
|
||||
);
|
||||
|
||||
// Assert translation if present
|
||||
if ($originalSlide->text_content_translated) {
|
||||
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}");
|
||||
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}");
|
||||
$this->assertSame(
|
||||
$originalSlide->text_content_translated,
|
||||
$reImportedSlide->getTranslation()?->getPlainText(),
|
||||
"Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
|
||||
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Assert arrangements match (same names, same group order)
|
||||
$reImportedArrangements = $reImported->getArrangements();
|
||||
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
|
||||
|
||||
foreach ($originalArrangements as $index => $originalArrangement) {
|
||||
foreach ($originalArrangements as $originalArrangement) {
|
||||
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
|
||||
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
|
||||
|
||||
$originalGroupNames = $originalArrangement->arrangementGroups
|
||||
$originalGroupNames = $originalArrangement->arrangementLabels
|
||||
->sortBy('order')
|
||||
->map(fn ($ag) => $ag->group?->name)
|
||||
->map(fn ($al) => $al->label?->name)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
@ -186,7 +199,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
);
|
||||
}
|
||||
|
||||
// 7. Assert CCLI metadata
|
||||
if ($originalSong->ccli_id) {
|
||||
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
|
||||
}
|
||||
|
|
@ -195,6 +207,96 @@ public function test_download_pro_roundtrip_preserves_content(): void
|
|||
}
|
||||
}
|
||||
|
||||
public function test_export_ohne_service_context_enthaelt_keine_macros(): void
|
||||
{
|
||||
$song = $this->createSongWithContent();
|
||||
$macro = $this->createMacroForExport('Service Macro');
|
||||
MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song);
|
||||
|
||||
foreach ($this->allParserSlides($parserSong) as $slide) {
|
||||
$this->assertFalse($slide->hasMacro());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_export_mit_globaler_song_zuweisung_enthaelt_macro_auf_allen_slides(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$song = $this->createSongWithContent();
|
||||
$macro = $this->createMacroForExport('Alle Folien Macro');
|
||||
MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||
$slides = $this->allParserSlides($parserSong);
|
||||
|
||||
$this->assertNotEmpty($slides);
|
||||
foreach ($slides as $slide) {
|
||||
$this->assertTrue($slide->hasMacro());
|
||||
$this->assertSame('Alle Folien Macro', $slide->getMacroName());
|
||||
$this->assertSame($macro->uuid, $slide->getMacroUuid());
|
||||
$this->assertSame('Export Collection', $slide->getMacroCollectionName());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_export_mit_ausgeblendeter_macro_enthaelt_keine_macro(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
$song = $this->createSongWithContent();
|
||||
$macro = $this->createMacroForExport('Ausgeblendete Macro', ['hidden_at' => now()]);
|
||||
MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||
|
||||
foreach ($this->allParserSlides($parserSong) as $slide) {
|
||||
$this->assertFalse($slide->hasMacro());
|
||||
}
|
||||
}
|
||||
|
||||
private function createMacroForExport(string $name, array $attributes = []): Macro
|
||||
{
|
||||
$macro = Macro::factory()->create(array_merge([
|
||||
'uuid' => '11111111-2222-4333-8444-555555555555',
|
||||
'name' => $name,
|
||||
], $attributes));
|
||||
|
||||
$collection = MacroCollection::create([
|
||||
'uuid' => '99999999-8888-4777-8666-555555555555',
|
||||
'name' => 'Export Collection',
|
||||
]);
|
||||
$collection->macros()->attach($macro->id, ['order' => 0]);
|
||||
|
||||
return $macro;
|
||||
}
|
||||
|
||||
private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array
|
||||
{
|
||||
$slides = [];
|
||||
|
||||
foreach ($parserSong->getGroups() as $group) {
|
||||
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
|
||||
return $slides;
|
||||
}
|
||||
|
||||
private function assertStringContains(string $needle, ?string $haystack): void
|
||||
{
|
||||
$this->assertNotNull($haystack);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ final class ProFileImportTest extends TestCase
|
|||
|
||||
private function test_pro_file(): UploadedFile
|
||||
{
|
||||
$sourcePath = base_path('../propresenter/doc/reference_samples/Test.pro');
|
||||
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
|
||||
|
||||
return new UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
|
||||
}
|
||||
|
|
@ -32,8 +32,10 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
|
|||
|
||||
$song = Song::where('title', 'Test')->first();
|
||||
$this->assertNotNull($song);
|
||||
$this->assertSame(4, $song->groups()->count());
|
||||
$this->assertSame(5, $song->groups()->withCount('slides')->get()->sum('slides_count'));
|
||||
|
||||
$this->assertSame(4, \App\Models\Label::count());
|
||||
$this->assertSame(5, \App\Models\SongSlide::count());
|
||||
|
||||
$this->assertSame(2, $song->arrangements()->count());
|
||||
$this->assertTrue($song->has_translation);
|
||||
}
|
||||
|
|
@ -64,9 +66,18 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void
|
|||
'title' => 'Old Title',
|
||||
'ccli_id' => '999',
|
||||
]);
|
||||
$existingSong->groups()->create(['name' => 'Old Group', 'color' => '#FF0000', 'order' => 0]);
|
||||
|
||||
$this->assertSame(1, $existingSong->groups()->count());
|
||||
$arrangement = $existingSong->arrangements()->create([
|
||||
'name' => 'Normal',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$oldLabel = \App\Models\Label::firstOrCreate(['name' => 'Old Group'], ['color' => '#FF0000']);
|
||||
$arrangement->arrangementLabels()->create([
|
||||
'label_id' => $oldLabel->id,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $arrangement->arrangementLabels()->count());
|
||||
|
||||
$existingSong->update(['ccli_id' => '999']);
|
||||
$this->assertSame(1, Song::count());
|
||||
|
|
@ -114,6 +125,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void
|
|||
|
||||
$this->assertNotNull($normalArrangement);
|
||||
$this->assertTrue($normalArrangement->is_default);
|
||||
$this->assertSame(5, $normalArrangement->arrangementGroups()->count());
|
||||
$this->assertSame(5, $normalArrangement->arrangementLabels()->count());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
tests/Feature/ServiceMacroOverrideControllerTest.php
Normal file
104
tests/Feature/ServiceMacroOverrideControllerTest.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceMacroAssignment;
|
||||
use App\Models\ServiceMacroOverride;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('override requires authentication', function () {
|
||||
$service = Service::factory()->create();
|
||||
$response = $this->post(route('services.macro-overrides.store', $service), ['part_type' => 'song']);
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('store creates override and snapshots global assignments', function () {
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'first_slide', 'order' => 1]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('services.macro-overrides.store', $service), ['part_type' => 'song']);
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
expect(ServiceMacroOverride::where('service_id', $service->id)->where('part_type', 'song')->exists())->toBeTrue();
|
||||
expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('store does not snapshot assignments from different part_type', function () {
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('services.macro-overrides.store', $service), ['part_type' => 'song']);
|
||||
|
||||
expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(1);
|
||||
expect(ServiceMacroAssignment::where('service_id', $service->id)->first()->part_type)->toBe('song');
|
||||
});
|
||||
|
||||
test('destroy removes override and service-specific assignments', function () {
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
ServiceMacroOverride::create(['service_id' => $service->id, 'part_type' => 'song']);
|
||||
ServiceMacroAssignment::create([
|
||||
'service_id' => $service->id,
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->deleteJson(route('services.macro-overrides.destroy', $service), ['part_type' => 'song']);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect(ServiceMacroOverride::where('service_id', $service->id)->count())->toBe(0);
|
||||
expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('storeAssignment creates service-level assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('services.macro-assignments.store', $service), [
|
||||
'part_type' => 'sermon',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'last_slide',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
expect(ServiceMacroAssignment::count())->toBe(1);
|
||||
expect(ServiceMacroAssignment::first()->service_id)->toBe($service->id);
|
||||
});
|
||||
|
||||
test('destroyAssignment removes service-level assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
$assignment = ServiceMacroAssignment::create([
|
||||
'service_id' => $service->id,
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->deleteJson(route('services.macro-assignments.destroy', [$service, $assignment]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect(ServiceMacroAssignment::count())->toBe(0);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue