Compare commits

..
5 Commits
237 changed files with 8009 additions and 9403 deletions

No files matched your search

-38
View File
@@ -1,38 +0,0 @@
name: redis
repository: ddev/ddev-redis
version: v2.2.0
install_date: "2026-09-08T18:12:40+02:00"
project_files:
- docker-compose.redis.yaml
- redis/scripts/settings.ddev.redis.php
- redis/scripts/setup-drupal-settings.sh
- redis/scripts/setup-redis-optimized-config.sh
- redis/redis.conf
- redis/advanced.conf
- redis/append.conf
- redis/general.conf
- redis/io.conf
- redis/memory.conf
- redis/network.conf
- redis/security.conf
- redis/snapshots.conf
- commands/host/redis-backend
- commands/redis/redis-cli
- commands/redis/redis-flush
global_files: []
removal_actions:
- |
#ddev-description:Remove redis settings if applicable
files=(
"${DDEV_APPROOT}/${DDEV_DOCROOT}/sites/default/settings.ddev.redis.php"
"${DDEV_APPROOT}/.ddev/docker-compose.redis_extra.yaml"
)
for file in "${files[@]}"; do
if [ -f "$file" ]; then
if grep -q '#ddev-generated' "$file"; then
rm -f "$file"
else
echo "Unwilling to remove '$file' because it does not have #ddev-generated in it; you can manually delete it if it is safe to delete."
fi
fi
done
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
#ddev-generated
## Description: Use a different key-value store for Redis
## Usage: redis-backend <image> [optimize]
## Example: ddev redis-backend redis-alpine optimize
REDIS_DOCKER_IMAGE=${1:-}
REDIS_CONFIG=${2:-}
NAME=$REDIS_DOCKER_IMAGE
function show_help() {
cat <<EOF
Usage: ddev redis-backend <image|alias> [optimize]
Choose from predefined aliases, or provide any Redis-compatible Docker image.
Note that not every Docker image can work right away, and you may need to override
the "command:" in the docker-compose.redis_extra.yaml file
Available aliases:
redis redis:7
redis-alpine redis:7-alpine
valkey valkey/valkey:8
valkey-alpine valkey/valkey:8-alpine
Custom backend:
You can specify any Docker image, e.g.:
ddev redis-backend redis:6
Optional:
optimize Apply additional Redis configuration with resource limits
optimized Same as optimize
Examples:
ddev redis-backend redis-alpine optimize
ddev redis-backend valkey
ddev redis-backend redis:7.2-alpine
EOF
exit 0
}
function optimize_config() {
[[ "$REDIS_CONFIG" != "optimized" && "$REDIS_CONFIG" != "optimize" ]] && return
ddev dotenv set .ddev/.env.redis --redis-optimized=true
}
function change_hostname() {
[[ "${REDIS_HOSTNAME:-}" == "" ]] && return
ddev dotenv set .ddev/.env.redis --redis-hostname="$REDIS_HOSTNAME"
}
function cleanup() {
rm -f "$DDEV_APPROOT/.ddev/.env.redis"
rm -rf "$DDEV_APPROOT/.ddev/redis/"
rm -f "$DDEV_APPROOT/.ddev/docker-compose.redis.yaml" "$DDEV_APPROOT/.ddev/docker-compose.redis_extra.yaml"
redis_volume="ddev-$(ddev status -j | docker run -i --rm ddev/ddev-utilities jq -r '.raw.name')_redis"
if docker volume ls -q | grep -qw "$redis_volume"; then
ddev stop
docker volume rm "$redis_volume"
fi
}
function check_docker_image() {
echo "Pulling ${REDIS_DOCKER_IMAGE}..."
if ! docker pull "$REDIS_DOCKER_IMAGE"; then
echo >&2 "❌ Unable to pull ${REDIS_DOCKER_IMAGE}"
exit 2
fi
}
function use_docker_image() {
[[ "$REDIS_DOCKER_IMAGE" != "redis:7" ]] && ddev dotenv set .ddev/.env.redis --redis-docker-image="$REDIS_DOCKER_IMAGE"
REPO=$(ddev add-on list --installed -j 2>/dev/null | docker run -i --rm ddev/ddev-utilities jq -r '.raw[] | select(.Name=="redis") | .Repository // empty' 2>/dev/null)
ddev add-on get "${REPO:-ddev/ddev-redis}"
}
case "$REDIS_DOCKER_IMAGE" in
redis)
NAME="Redis 7"
REDIS_DOCKER_IMAGE="redis:7"
;;
redis-alpine)
NAME="Redis 7 Alpine"
REDIS_DOCKER_IMAGE="redis:7-alpine"
;;
valkey)
NAME="Valkey 8"
REDIS_DOCKER_IMAGE="valkey/valkey:8"
REDIS_HOSTNAME="valkey"
;;
valkey-alpine)
NAME="Valkey 8 Alpine"
REDIS_DOCKER_IMAGE="valkey/valkey:8-alpine"
REDIS_HOSTNAME="valkey"
;;
""|--help|-h)
show_help
;;
*)
NAME="$REDIS_DOCKER_IMAGE"
# Allow unknown image, nothing to override
;;
esac
check_docker_image
cleanup
optimize_config
change_hostname
use_docker_image
echo
echo "✅ Redis backend: $REDIS_DOCKER_IMAGE"
if [[ "$REDIS_CONFIG" == "optimized" || "$REDIS_CONFIG" == "optimize" ]]; then
echo "⚙️ Redis config: optimized"
else
echo "⚙️ Redis config: default"
fi
echo
echo "📝 Commit the '.ddev' directory to version control"
echo
echo "🔄 Redis config available after 'ddev restart'"
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env sh
#ddev-generated
## Description: Run redis-cli inside the Redis container
## Usage: redis-cli [flags] [args]
## Example: "ddev redis-cli KEYS *" or "ddev redis-cli INFO" or "ddev redis-cli --version"
## Aliases: redis
if [ -f /etc/redis/conf/security.conf ]; then
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" -a redis --no-auth-warning $@
else
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" $@
fi
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env sh
#ddev-generated
## Description: Flush all cache inside the Redis container
## Usage: redis-flush
## Example: "ddev redis-flush"
if [ -f /etc/redis/conf/security.conf ]; then
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" -a redis --no-auth-warning FLUSHALL ASYNC
else
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" FLUSHALL ASYNC
fi
+42 -79
View File
@@ -1,8 +1,9 @@
name: ep-reisen
type: typo3 type: typo3
docroot: public docroot: public
php_version: "7.4" php_version: "7.4"
webserver_type: nginx-fpm webserver_type: nginx-fpm
router_http_port: "80"
router_https_port: "443"
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: additional_hostnames:
- ep-events - ep-events
@@ -15,58 +16,36 @@ additional_hostnames:
additional_fqdns: [] additional_fqdns: []
database: database:
type: mariadb type: mariadb
version: "11.4" version: "10.2"
hooks:
post-start:
- exec: .ddev/claude-code/install.sh
- exec: .ddev/codex-cli/install.sh
webimage_extra_packages: [ripgrep, bubblewrap, socat]
use_dns_when_possible: true use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: web_environment:
- TYPO3_CONTEXT=Development - TYPO3_CONTEXT=Development
nodejs_version: "24" nodejs_version: "18"
corepack_enable: false
omit_containers: [ddev-ssh-agent]
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides # name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site # 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, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock # type: <projecttype> # drupal6/7/8, backdrop, typo3, wordpress, php
# 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. # docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.4" # PHP version to use, "5.6" through "8.5" # php_version: "8.1" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior, # is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # webimage: <docker_image> # nginx/php 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: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.4" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3 # MariaDB versions can be 5.5-10.8 and 10.11, MySQL versions can be 5.5-8.0
# MySQL versions can be 5.5-8.0, 8.4, 9.7 # PostgreSQL versions can be 9-16.
# PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <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 db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # 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) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -76,24 +55,14 @@ omit_containers: [ddev-ssh-agent]
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, # "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. # as leaving Xdebug enabled all the time is a big performance hit.
# xhgui_http_port: "8143" # xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart"
# xhgui_https_port: "8142" # Note that for most people the commands
# The XHGui ports can be changed from the default 8143 and 8142 # "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better,
# Very rarely used # as leaving Xhprof enabled all the time is a big performance hit.
# host_xhgui_port: "8142" # webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn
# 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 # 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; # This is the timezone used in the containers and by PHP;
# it can be set to any valid timezone, # it can be set to any valid timezone,
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
@@ -105,27 +74,22 @@ omit_containers: [ddev-ssh-agent]
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 # You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use any other Composer version channel. This includes: # It is also possible to use each other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # 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". # To reinstall Composer after the image was built, run "ddev debug refresh".
# nodejs_root: <relative_path> # nodejs_version: "18"
# Relative path to the directory containing the Node.js version file from the # change from the default system Node.js version to another supported version, like 16, 18, 20.
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine". # Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any
# Node.js version, including v6, etc.
# nodejs_version: "24" # You only need to configure this if you are not using nvm and you want to use a major
# change from the default system Node.js version to any other version. # version that is not the default.
# 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: # additional_hostnames:
# - somename # - somename
@@ -155,7 +119,7 @@ omit_containers: [ddev-ssh-agent]
# ddev_version_constraint: "" # ddev_version_constraint: ""
# Example: # Example:
# ddev_version_constraint: ">= 1.24.8" # ddev_version_constraint: ">= 1.22.4"
# This will enforce that the running ddev version is within this constraint. # This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for # See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats # supported constraint formats
@@ -182,8 +146,10 @@ omit_containers: [ddev-ssh-agent]
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs
# See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook # Decide whether 'ddev start' should be interrupted by a failing hook
@@ -212,10 +178,10 @@ omit_containers: [ddev-ssh-agent]
# The mailpit port is not normally bound on the host at all, instead being routed # 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. # 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'] # webimage_extra_packages: [php7.4-tidy, php-bcmath]
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [netcat, telnet, sudo] # dbimage_extra_packages: [telnet,netcat]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -227,25 +193,22 @@ omit_containers: [ddev-ssh-agent]
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# share_default_provider: ngrok # ngrok_args: --basic-auth username:pass1234
# The default share provider to use for "ddev share" # Provide extra flags to the "ngrok http" command, see
# Defaults to global configuration, usually "ngrok" # https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h"
# 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 # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php # Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php
# In this case the user must provide all such settings. # In this case the user must provide all such settings.
# You can inject environment variables into the web container with: # You can inject environment variables into the web container with:
# web_environment: # web_environment:
# - SOMEENV=somevalue # - SOMEENV=somevalue
# - SOMEOTHERENV=someothervalue # - SOMEOTHERENV=someothervalue
# no_project_mount: false # no_project_mount: false
# (Experimental) If true, DDEV will not mount the project into the web container; # (Experimental) If true, DDEV will not mount the project into the web container;
@@ -297,7 +260,7 @@ omit_containers: [ddev-ssh-agent]
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # 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 # 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. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -307,13 +270,13 @@ omit_containers: [ddev-ssh-agent]
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended effect. 'override_config' affects only behavior of the # can have their intended affect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db", # DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer" # "pre-composer", "post-composer"
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more # See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define # information on the commands that can be extended and the tasks you can define
# for them. Example: # for them. Example:
#hooks: #hooks:
-27
View File
@@ -1,27 +0,0 @@
#ddev-generated
services:
redis:
container_name: ddev-${DDEV_SITENAME}-redis
image: ${REDIS_DOCKER_IMAGE:-redis:7}
hostname: ${REDIS_HOSTNAME:-redis}
# These labels ensure this service is discoverable by ddev.
labels:
com.ddev.site-name: ${DDEV_SITENAME}
com.ddev.approot: ${DDEV_APPROOT}
restart: "no"
expose:
- 6379
volumes:
- ".:/mnt/ddev_config"
- "ddev-global-cache:/mnt/ddev-global-cache"
- "./redis:/etc/redis/conf"
- "redis:/data"
command: /etc/redis/conf/redis.conf
x-ddev:
describe-url-port: |
Backend: ${REDIS_DOCKER_IMAGE:-redis:7}
describe-info: |
Pass: <none>
volumes:
redis:
-13
View File
@@ -1,13 +0,0 @@
# Redis configuration.
# #ddev-generated
# Example configuration files for reference:
# http://download.redis.io/redis-stable/redis.conf
# http://download.redis.io/redis-stable/sentinel.conf
maxmemory 128mb
maxmemory-policy allkeys-lru
# to disable Redis persistence, remove ddev-generated from this file,
# and uncomment the two lines below:
#appendonly no
#save ""
+1 -1
View File
@@ -15,9 +15,9 @@ public/typo3conf/ext/*/
public/typo3conf/ext/ep_events/Resources/Public/ public/typo3conf/ext/ep_events/Resources/Public/
public/typo3conf/ext/ep_theme/Resources/Public/ public/typo3conf/ext/ep_theme/Resources/Public/
public/typo3conf/LocalConfiguration.php public/typo3conf/LocalConfiguration.php
public/typo3conf/AdditionalConfiguration.php
public/typo3conf/PackageStates.php public/typo3conf/PackageStates.php
public/typo3conf/l10n public/typo3conf/l10n
public/typo3temp public/typo3temp
public/uploads public/uploads
var var
http-client.private.env.json
+3284
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
24 16
+7 -37
View File
@@ -1,42 +1,12 @@
# Backchannel endpoints MyE&P reads hotel and product data from. Same environment files and key
# as content-feed.http: pick "local" and put cmsApiKey in http-client.private.env.json.
### GET product data for myep ### GET product data for myep
GET {{host}}/api/product?product=3VIIN&hotel=VLTS GET https://ep-reisen.ddev.site/api/product
X-Api-Key: {{cmsApiKey}} ?product=SSTMR
&hotel=SSTGR
> {% &key=abcabc123
client.test("200 with key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### GET hotel data for myep ### GET hotel data for myep
GET {{host}}/api/hotel?hotel=DPWGRP GET https://ep-reisen.ddev.site/api/hotel
X-Api-Key: {{cmsApiKey}} ?hotel=DPWGRP
&key=abcabc123
> {%
client.test("200 with key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### Transition form: key as query parameter, as MyE&P sends it today (still accepted)
GET {{host}}/api/hotel?hotel=DPWGRP&key={{cmsApiKey}}
> {%
client.test("200 with query key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### Any other value is refused now - this used to pass
GET {{host}}/api/hotel?hotel=DPWGRP&key=AbcAbc123
> {%
client.test("403 with the old dummy key", function () {
client.assert(response.status === 403, "expected 403, got " + response.status);
});
%}
### ###
+1 -4
View File
@@ -15,7 +15,6 @@
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-redis": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"b13/container": "^1.3", "b13/container": "^1.3",
"blueways/bw-captcha": "^3.1", "blueways/bw-captcha": "^3.1",
@@ -25,13 +24,11 @@
"georgringer/news": "^8.5", "georgringer/news": "^8.5",
"gridelementsteam/gridelements": "^10.0", "gridelementsteam/gridelements": "^10.0",
"helhum/typo3-console": "^6.4", "helhum/typo3-console": "^6.4",
"jweiland/replacer": "^2.1",
"kigkonsult/icalcreator": "^2.29", "kigkonsult/icalcreator": "^2.29",
"league/csv": "^9.2", "league/csv": "^9.2",
"league/oauth2-client": "^2.8", "league/oauth2-client": "^2.8",
"league/period": "^4.9", "league/period": "^4.9",
"lochmueller/staticfilecache": "^12.5", "lochmueller/staticfilecache": "^12.5",
"phpseclib/phpseclib": "^3.0",
"sjbr/sr-freecap": "2.6.0", "sjbr/sr-freecap": "2.6.0",
"ssch/typo3-encore": "^3.0", "ssch/typo3-encore": "^3.0",
"symfony/http-client": "^4.3", "symfony/http-client": "^4.3",
@@ -98,7 +95,7 @@
"config": { "config": {
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "7.4.33" "php": "7.4.13"
}, },
"allow-plugins": { "allow-plugins": {
"typo3/cms-composer-installers": true, "typo3/cms-composer-installers": true,
Generated
+35 -330
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "38fb94ebf504e8413d37c94f929e4b47", "content-hash": "f0ff2587673ce0da1d0d53cc382a0922",
"packages": [ "packages": [
{ {
"name": "b13/container", "name": "b13/container",
@@ -1583,72 +1583,6 @@
], ],
"time": "2023-01-08T21:22:53+00:00" "time": "2023-01-08T21:22:53+00:00"
}, },
{
"name": "jweiland/replacer",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/jweiland-net/replacer.git",
"reference": "feec487ea9c3573d1e8d80245dd9763352492357"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jweiland-net/replacer/zipball/feec487ea9c3573d1e8d80245dd9763352492357",
"reference": "feec487ea9c3573d1e8d80245dd9763352492357",
"shasum": ""
},
"require": {
"typo3/cms-core": "^9.5 || ^10.4 || ^11.0"
},
"replace": {
"typo3-ter/replacer": "self.version"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"nimut/testing-framework": "^6.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-latest"
},
"type": "typo3-cms-extension",
"extra": {
"typo3/cms": {
"app-dir": ".build",
"web-dir": ".build/public",
"extension-key": "replacer"
}
},
"autoload": {
"psr-4": {
"JWeiland\\Replacer\\": "Classes"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Pascal Rinker",
"email": "[email protected]",
"role": "Developer"
}
],
"description": "Replaces string patterns from the page. You can use it to replace URLs for Content Delivery Network (CDN).",
"homepage": "http://www.jweiland.net",
"keywords": [
"TYPO3 CMS",
"jw",
"replacer",
"typo3"
],
"support": {
"email": "[email protected]",
"issues": "https://github.com/jweiland-net/replacer/issues",
"source": "https://github.com/jweiland-net/replacer"
},
"time": "2022-06-02T09:29:20+00:00"
},
{ {
"name": "kigkonsult/icalcreator", "name": "kigkonsult/icalcreator",
"version": "v2.39.2", "version": "v2.39.2",
@@ -1817,22 +1751,21 @@
}, },
{ {
"name": "league/oauth2-client", "name": "league/oauth2-client",
"version": "2.9.1", "version": "2.9.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git", "url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71" "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/8cedfef9d01a8d1fd2ecfabb41734b17592c4b71", "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71", "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8 || ^7.8.2 || ^8.0", "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
"guzzlehttp/psr7": "^1.9.1 || ^2.6.3 || ^3.0",
"php": "^7.1 || >=8.0.0 <8.6.0" "php": "^7.1 || >=8.0.0 <8.6.0"
}, },
"require-dev": { "require-dev": {
@@ -1877,9 +1810,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/oauth2-client/issues", "issues": "https://github.com/thephpleague/oauth2-client/issues",
"source": "https://github.com/thephpleague/oauth2-client/tree/2.9.1" "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0"
}, },
"time": "2026-09-16T13:14:31+00:00" "time": "2025-11-25T22:17:17+00:00"
}, },
{ {
"name": "league/period", "name": "league/period",
@@ -2122,24 +2055,24 @@
}, },
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
"version": "2.11.0", "version": "2.10.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Masterminds/html5-php.git", "url": "https://github.com/Masterminds/html5-php.git",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" "reference": "fd5018f6815fff903946d0564977b44ce8010e29"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", "reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-dom": "*", "ext-dom": "*",
"php": ">=7.4" "php": ">=5.3.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -2183,9 +2116,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/Masterminds/html5-php/issues", "issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0" "source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
}, },
"time": "2026-08-18T06:18:41+00:00" "time": "2026-06-23T18:43:15+00:00"
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
@@ -2238,123 +2171,6 @@
}, },
"time": "2025-12-06T11:45:25+00:00" "time": "2025-12-06T11:45:25+00:00"
}, },
{
"name": "paragonie/constant_time_encoding",
"version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
"reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
"shasum": ""
},
"require": {
"php": "^7|^8"
},
"require-dev": {
"phpunit/phpunit": "^6|^7|^8|^9",
"vimeo/psalm": "^1|^2|^3|^4"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "[email protected]",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "[email protected]",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "[email protected]",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:12:37+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "[email protected]",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "[email protected]",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.0",
@@ -2530,128 +2346,18 @@
}, },
"time": "2025-11-21T15:09:14+00:00" "time": "2025-11-21T15:09:14+00:00"
}, },
{
"name": "phpseclib/phpseclib",
"version": "3.0.57",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "[email protected]",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "[email protected]",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "[email protected]",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "[email protected]",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "[email protected]",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-08-26T12:13:21+00:00"
},
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.5", "version": "2.3.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2683,9 +2389,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
}, },
"time": "2026-08-31T16:05:28+00:00" "time": "2026-07-08T07:01:06+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -5111,16 +4817,16 @@
}, },
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.42.0", "version": "v1.38.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git", "url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" "reference": "dc21118016c039a66235cf93d96b435ffb282412"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
"reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "reference": "dc21118016c039a66235cf93d96b435ffb282412",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5174,7 +4880,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
}, },
"funding": [ "funding": [
{ {
@@ -5194,20 +4900,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-24T10:51:20+00:00" "time": "2026-05-25T15:22:23+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-normalizer", "name": "symfony/polyfill-intl-normalizer",
"version": "v1.42.0", "version": "v1.38.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5259,7 +4965,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
}, },
"funding": [ "funding": [
{ {
@@ -5279,7 +4985,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-07T06:33:24+00:00" "time": "2026-05-25T13:48:31+00:00"
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
@@ -9648,12 +9354,11 @@
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-redis": "*",
"ext-simplexml": "*" "ext-simplexml": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"platform-overrides": { "platform-overrides": {
"php": "7.4.33" "php": "7.4.13"
}, },
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
} }
-141
View File
@@ -1,141 +0,0 @@
# Chatbot content feed (see .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md)
#
# Pick the "local" environment in PhpStorm and run the whole file top to bottom: the manifest
# request stores uids and a content URL that the later requests use.
#
# Needs, locally:
# - http-client.private.env.json with {"local": {"cmsApiKey": "<your key>"}} (gitignored)
# - the same key in TYPO3, e.g.
# ddev exec vendor/bin/typo3cms configuration:set EXTENSIONS/ep_products/cmsApiKey <your key>
# plus contentFeedSites etc. - or open and save the ep_products extension configuration once
# in the backend, which writes all the defaults.
#
# The kill switch (contentFeedEnabled = 0) cannot be toggled from here; with it off, every keyed
# request below answers 503.
### Manifest without a key is refused
GET {{host}}/api/content/manifest
> {%
client.test("403 without key", function () {
client.assert(response.status === 403, "expected 403, got " + response.status);
});
%}
### Manifest
GET {{host}}/api/content/manifest
X-Api-Key: {{cmsApiKey}}
> {%
client.test("manifest is complete and well-formed", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.length > 0, "manifest is empty");
client.assert(response.body.sites.length > 0, "no sites");
client.assert(
response.body.items.every(function (item) { return item.type && item.uid && item.modified; }),
"an item lacks type, uid or modified"
);
});
var uidsOf = function (type, limit) {
return response.body.items
.filter(function (item) { return item.type === type; })
.slice(0, limit)
.map(function (item) { return item.uid; })
.join(",");
};
client.global.set("productUids", uidsOf("product", 5));
client.global.set("hotelUids", uidsOf("hotel", 5));
client.global.set("regionUids", uidsOf("region", 5));
client.global.set("newsUids", uidsOf("news", 3));
var page = response.body.items.filter(function (item) { return item.type === "page" && item.contentUrl; })[0];
client.global.set("contentUrl", page.contentUrl);
var tooMany = [];
for (var i = 1; i <= 101; i++) { tooMany.push(i); }
client.global.set("tooManyUids", tooMany.join(","));
%}
### Products (with FAQs)
GET {{host}}/api/content/records?type=product&uids={{productUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("every requested product comes back, links resolved", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.length === client.global.get("productUids").split(",").length, "products missing");
client.assert(JSON.stringify(response.body).indexOf("t3://") === -1, "unresolved t3:// link");
});
%}
### Hotels (codes include matchcode aliases)
GET {{host}}/api/content/records?type=hotel&uids={{hotelUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("hotels carry their codes", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.every(function (hotel) { return Array.isArray(hotel.codes); }), "hotel without codes");
});
%}
### Regions
GET {{host}}/api/content/records?type=region&uids={{regionUids}}
X-Api-Key: {{cmsApiKey}}
### Blog articles
GET {{host}}/api/content/records?type=news&uids={{newsUids}}
X-Api-Key: {{cmsApiKey}}
### Unknown type is a 400
GET {{host}}/api/content/records?type=page&uids=1
X-Api-Key: {{cmsApiKey}}
> {%
client.test("400 for unknown type", function () {
client.assert(response.status === 400, "expected 400, got " + response.status);
});
%}
### More than 100 uids is a 400
GET {{host}}/api/content/records?type=product&uids={{tooManyUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("400 above 100 uids", function () {
client.assert(response.status === 400, "expected 400, got " + response.status);
});
%}
### Content-only render of the first page in the manifest
GET {{contentUrl}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("bare article, never cached by proxies", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.indexOf("<article>") === 0, "not a bare <article> fragment");
client.assert(response.headers.valueOf("Cache-Control") === "private, no-store", "unexpected Cache-Control");
});
%}
### Content-only render without a key is a 404 (the type must not be discoverable)
GET {{contentUrl}}
> {%
client.test("404 without key", function () {
client.assert(response.status === 404, "expected 404, got " + response.status);
});
%}
### Content-only render with a wrong key is a 404
GET {{contentUrl}}
X-Api-Key: definitely-wrong
> {%
client.test("404 with wrong key", function () {
client.assert(response.status === 404, "expected 404, got " + response.status);
});
%}
+41 -24
View File
@@ -25,7 +25,6 @@ add('shared_dirs', [
$rsyncOptions = [ $rsyncOptions = [
'exclude' => [ 'exclude' => [
'.ddev', '.ddev',
'.claude',
'.DS_Store', '.DS_Store',
'.git', '.git',
'.github', '.github',
@@ -38,6 +37,7 @@ $rsyncOptions = [
'var', 'var',
'.editorconfig', '.editorconfig',
'.gitignore', '.gitignore',
'.htaccess_prod',
'deploy.php', 'deploy.php',
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
@@ -51,56 +51,73 @@ $rsyncOptions = [
'filter' => [], 'filter' => [],
'filter-file' => false, 'filter-file' => false,
'filter-perdir' => false, 'filter-perdir' => false,
// -t preserves mtimes: without it every file lands with a fresh timestamp, so unchanged 'flags' => 'rz',
// files get a new Last-Modified on every deploy and every conditional request revalidates
// into a full response instead of a 304.
'flags' => 'rzt',
'options' => ['delete', 'links', 'chmod=D755,F664'], 'options' => ['delete', 'links', 'chmod=D755,F664'],
'timeout' => 300, 'timeout' => 300,
]; ];
host('prod') host('production')
->setHostname('dedi10193.your-server.de') ->setHostname('185.237.67.190')
->setRemoteUser('eptypo3') ->setRemoteUser('p546128')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/usr/home/eptypo3/public_html/{{application}}') ->setDeployPath('/home/www/p546128/html/{{application}}')
->set('bin/php', '/usr/bin/php74') ->set('buspronet_path', '/home/www/p546128/html/kuschick/buspronet/buspronet')
->set('http_user', 'eptypo3') ->set('bin/php', '/usr/local/bin/php')
->set('http_user', 'p546128')
->set('writable_mode', 'chmod') ->set('writable_mode', 'chmod')
->set('bin/composer', '/usr/local/bin/composer')
->set('composer_options', '--prefer-dist --no-progress --no-interaction --optimize-autoloader')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
// The temp script goes into the shared typo3temp, which resolves to the same directory from every ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://www.ep-reisen.de')
// release: PHP workers with a stale realpath cache still map "current" to the previous release and ;
// would 404 on a script placed inside the new one.
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{deploy_path}}/shared/{{typo3_webroot}}/typo3temp/ --web-url=https://www.ep-reisen.de/typo3temp') $sharedFilesStaging = get('shared_files');
$sharedFilesStaging[] = '{{typo3_webroot}}/.htpasswd';
host('staging')
->setHostname('185.237.67.190')
->setRemoteUser('p546128')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p546128/html/{{application}}-staging')
->set('buspronet_path', '/home/www/p546128/html/kuschick/buspronet/buspronet')
->set('bin/php', '/usr/local/bin/php')
->set('http_user', 'p546128')
->set('writable_mode', 'chmod')
->set('bin/composer', '/usr/local/bin/composer')
->set('composer_options', '--prefer-dist --no-progress --no-interaction --optimize-autoloader')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://staging.ep-reisen.de --web-basic-auth=ep:reisen')
->set('shared_files', $sharedFilesStaging)
; ;
task('deploy', [ task('deploy', [
'deploy:info', 'deploy:info',
// Build locally first, so a failing build aborts before the server is touched at all.
'deploy:assets:build',
'deploy:setup', 'deploy:setup',
'deploy:lock', 'deploy:lock',
'deploy:release', 'deploy:release',
'deploy:assets:build',
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
// The schema has to exist before the new code starts serving traffic.
'typo3:database:migrate', 'typo3:database:migrate',
'typo3:language:update', 'typo3:language:update',
// deploy:publish == deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success 'typo3:cache:flush',
'cachetool:clear:opcache',
'deploy:publish', 'deploy:publish',
'deploy:buspronet:symlink',
]); ]);
// Caches belong to the release that goes live, so they are cleared after the symlink flip. Hooking task('deploy:buspronet:symlink', function () {
// into deploy:symlink rather than appending to the list above keeps them inside deploy:publish, ahead run('ln -s {{buspronet_path}} {{release_or_current_path}}/public/buchung');
// of deploy:unlock and deploy:success - a failing cache task must not follow a declared success. });
after('deploy:symlink', 'cachetool:clear:opcache');
after('deploy:symlink', 'typo3:cache:flush');
task ('deploy:assets:build', function () { task ('deploy:assets:build', function () {
runLocally('ddev exec npm ci'); runLocally('ddev exec npm ci');
runLocally('ddev exec npm rebuild node-sass');
runLocally('ddev exec npm run build'); runLocally('ddev exec npm run build');
}); });
-8
View File
@@ -1,8 +0,0 @@
{
"local": {
"host": "https://ep-reisen.ddev.site"
},
"prod": {
"host": "https://www.ep-reisen.de"
}
}
+1605 -1412
View File
File diff suppressed because it is too large. Load diff
+23 -9
View File
@@ -1,48 +1,62 @@
{ {
"name": "eptheme", "name": "eptheme",
"description": "EP Reisen TYPO3 website package",
"version": "3.0.0",
"author": "Björn Fromme, [email protected]",
"license": "proprietary",
"scripts": { "scripts": {
"dev": "./node_modules/.bin/encore dev --watch", "dev": "./node_modules/.bin/encore dev --watch",
"build": "NODE_ENV=production ./node_modules/.bin/encore production" "build": "NODE_ENV=production ./node_modules/.bin/encore production"
}, },
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=16"
}, },
"browserslist": [
"defaults"
],
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.1", "@hotwired/stimulus": "^3.2.1",
"@hotwired/stimulus-webpack-helpers": "^1.0.1", "@hotwired/stimulus-webpack-helpers": "^1.0.1",
"@iframe-resizer/parent": "^5.3.2", "@iframe-resizer/parent": "^5.3.2",
"@splidejs/splide": "^2.4.21", "@splidejs/splide": "^2.4.21",
"axios": "^1.6.2",
"cooltipz-css": "^1.6.8", "cooltipz-css": "^1.6.8",
"dayjs": "^1.10.6", "dayjs": "^1.10.6",
"es6-slide-up-down": "^1.0.0",
"flatpickr": "^4.6.9", "flatpickr": "^4.6.9",
"glightbox": "^3.0.9", "glightbox": "^3.0.9",
"htmx.org": "^1.9.12", "htmx.org": "^1.9.12",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lazysizes": "^5.3.2",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"picturefill": "^3.0.3",
"slick-carousel": "^1.8.1", "slick-carousel": "^1.8.1",
"sticky-js": "^1.3.0", "sticky-js": "^1.3.0",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-session": "^1.0.0" "vue-router": "^3.5.2",
"vue-session": "^1.0.0",
"vuex": "^3.6.2",
"whatwg-fetch": "^3.6.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-object-rest-spread": "^7.16.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^4.7.0", "@symfony/webpack-encore": "^2.1.0",
"@tailwindcss/aspect-ratio": "^0.4.0", "@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"core-js": "^3.22.5",
"document-register-element": "^1.14.10",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"postcss": "^8.4.16", "postcss": "^8.4.16",
"postcss-loader": "^8.2.1", "postcss-loader": "^6.2.1",
"sass": "^1.53.0", "sass": "^1.53.0",
"sass-loader": "^14.2.1", "sass-loader": "^12.6.0",
"sass-mq": "^7.0.1", "sass-mq": "^6.0.0",
"smoothscroll-polyfill": "^0.4.4",
"tailwindcss": "^3.1.8", "tailwindcss": "^3.1.8",
"tailwindcss-alt": "^3.0.0", "tailwindcss-alt": "^3.0.0",
"vue-custom-element": "^3.3.0",
"vue-loader": "^15.11.1", "vue-loader": "^15.11.1",
"vue-template-compiler": "^2.6.14" "vue-template-compiler": "^2.6.14"
} }
+10 -114
View File
@@ -75,58 +75,6 @@ AddType image/webp .webp
</IfModule> </IfModule>
</IfModule> </IfModule>
# Brotli, where the host has the module. It beats gzip by roughly 15-20% on the theme's
# minified css/js at no measurable cost, and falls back to the mod_deflate block above for
# clients that do not announce `br` - which is also what happens wholesale if the module is
# missing, because the whole block is then skipped.
#
# The type list is deliberately the same one mod_deflate gets: `no-gzip` below switches gzip
# off for every br-capable client, so a type listed for DEFLATE only would reach those
# clients uncompressed.
<IfModule mod_brotli.c>
# Both filters would otherwise run for a client announcing `br, gzip` and gzip the brotli
# output on top of itself. mod_deflate honours `no-gzip`, so brotli wins where it is
# offered and gzip keeps everyone else.
<IfModule mod_setenvif.c>
SetEnvIfNoCase Accept-Encoding "(^|[\s,])br($|[\s,;])" no-gzip
</IfModule>
<IfModule mod_filter.c>
AddOutputFilterByType BROTLI_COMPRESS application/atom+xml \
application/javascript \
application/json \
application/ld+json \
application/manifest+json \
application/rdf+xml \
application/rss+xml \
application/schema+json \
application/vnd.geo+json \
application/vnd.ms-fontobject \
application/x-font-ttf \
application/x-javascript \
application/x-web-app-manifest+json \
application/xhtml+xml \
application/xml \
font/eot \
font/opentype \
image/bmp \
image/svg+xml \
image/vnd.microsoft.icon \
image/x-icon \
text/cache-manifest \
text/css \
text/html \
text/javascript \
text/plain \
text/vcard \
text/vnd.rim.location.xloc \
text/vtt \
text/x-component \
text/x-cross-domain-policy \
text/xml
</IfModule>
</IfModule>
### End: Compression ### ### End: Compression ###
@@ -191,16 +139,6 @@ AddType image/webp .webp
</IfModule> </IfModule>
# Webpack writes an 8-hex-character content hash into the filename of everything it emits, so
# these URLs can never change meaning - a new build is a new URL. `immutable` stops browsers
# revalidating them on reload, and overrides the per-type rules above, which would otherwise
# expire the hashed fonts after a month.
<IfModule mod_headers.c>
<FilesMatch "\.[0-9a-f]{8}\.(js|css|woff2?|jpe?g|png|gif|svg|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
### End: Browser caching of resource files ### ### End: Browser caching of resource files ###
@@ -285,6 +223,9 @@ AddDefaultCharset utf-8
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$"> <FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Origin "*"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(png|gif|jpe?g)$">
Header append Vary Accept
</FilesMatch>
</IfModule> </IfModule>
### End: Cross Origin ### ### End: Cross Origin ###
@@ -300,58 +241,7 @@ AddDefaultCharset utf-8
# Enable URL rewriting # Enable URL rewriting
RewriteEngine On RewriteEngine On
# Legacy and campaign domains, consolidated onto their current host. These have to stay # Force SSL
# ahead of the Force SSL block and of TYPO3's catch-all at the end of this file, or a
# request for one of them reaches index.php before it is ever redirected.
#
# Maintained here rather than on the host: the deploy rsyncs this file into every release
# (deploy.php), so anything edited live is gone after the next one.
RewriteRule ^buchung/(.*)$ https://bpn.ep-reisen.de/$1 [R=302,L,QSA]
RewriteCond %{HTTP_HOST} ^cllt\-touristik\.at$ [NC]
RewriteRule ^(.*) https://www.cllt-touristik.at/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^alpinevacation\.ch$ [NC]
RewriteRule ^(.*) https://www.alpinevacation.ch/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?semesterendeskireisen\.de$ [NC]
RewriteRule ^(.*) https://www.semesterende-skireisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^semesterende\-skireisen\.de$ [NC]
RewriteRule ^(.*) https://www.semesterende-skireisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^snowzone\.net$ [NC]
RewriteRule ^(.*) https://www.snowzone.net/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-boarderweek\.de$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ski\-boarderweek\.(com|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} skiboarderweek\.(de|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-wochenenden\.de$ [NC]
RewriteRule ^(.*) https://www.ski-wochenenden.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-events\.de$ [NC]
RewriteRule ^(.*) https://www.ep-events.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-reisen\.de$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
# The dropped path is deliberate: snow-break sends every deep link to the snowbreak
# landing page, unlike unichamp below, which keeps it.
RewriteCond %{HTTP_HOST} ^(www\.)?snow\-break\.(de|com) [NC]
RewriteRule (.*) https://snowbreak.ep-reisen.de/ [R=302,L]
# Matches the bare domain as well, so it lands on snowbreak in one hop - a separate
# unichamp.de -> www.unichamp.de rule used to run first and cost an extra 301.
RewriteCond %{HTTP_HOST} ^(www\.)?unichamp\.de$ [NC]
RewriteRule ^(.*) https://snowbreak.ep-reisen.de/$1 [L,R=302]
# Force SSL
RewriteCond %{HTTPS} off RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} =http RewriteCond %{HTTP:X-Forwarded-Proto} =http
RewriteRule (.*) https://%{HTTP_HOST}/$1 [R=301,L] RewriteRule (.*) https://%{HTTP_HOST}/$1 [R=301,L]
@@ -392,6 +282,12 @@ AddDefaultCharset utf-8
# NOTE: Add your additional local storages here # NOTE: Add your additional local storages here
RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/|favicon\.ico) - [L] RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/|favicon\.ico) - [L]
RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{REQUEST_FILENAME} (.*)\.(png|gif|jpe?g)$
RewriteCond %{REQUEST_FILENAME}\.webp -f
RewriteRule ^ %{REQUEST_FILENAME}\.webp [L,T=image/webp]
### Begin: StaticFileCache (preparation) #### ### Begin: StaticFileCache (preparation) ####
# Document root configuration # Document root configuration
@@ -1,127 +0,0 @@
<?php
$redisOptions = [
'hostname' => '/run/redis_eptypo3/redis.sock',
'port' => 0,
];
if (getenv('IS_DDEV_PROJECT') == 'true') {
$redisOptions = [
'hostname' => 'redis',
'port' => 6379,
];
$GLOBALS['TYPO3_CONF_VARS'] = array_replace_recursive(
$GLOBALS['TYPO3_CONF_VARS'],
[
'DB' => [
'Connections' => [
'Default' => [
'dbname' => 'db',
'driver' => 'mysqli',
'host' => 'db',
'password' => 'db',
'port' => '3306',
'user' => 'db',
],
],
],
// This GFX configuration allows processing by installed ImageMagick 6
'GFX' => [
'processor' => 'ImageMagick',
'processor_path' => '/usr/bin/',
'processor_path_lzw' => '/usr/bin/',
],
// This mail configuration sends all emails to mailpit
'MAIL' => [
'transport' => 'smtp',
'transport_smtp_encrypt' => false,
'transport_smtp_server' => 'localhost:1025',
],
'SYS' => [
'trustedHostsPattern' => '.*.*',
'devIPmask' => '*',
'displayErrors' => 1,
],
]
);
}
// Fail fast instead of blocking indefinitely when redis is unreachable.
$redisOptions['connectionTimeout'] = 2;
// Note: the cache identifiers must stay unprefixed ('pages', not 'cache_pages'). These
// assignments replace the definitions from LocalConfiguration.php wholesale, so 'groups'
// and the compression/lifetime options have to be repeated here.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + [
'database' => 0,
'compression' => true,
],
'groups' => ['pages'],
];
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['hash'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + [
'database' => 1,
],
'groups' => ['pages'],
];
// Own database: RedisBackend::flush() issues FLUSHDB and the keys carry no per-cache
// prefix, so two caches sharing a database flush each other.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + [
'database' => 2,
'compression' => true,
'defaultLifetime' => 2592000,
],
'groups' => ['pages'],
];
// Assets are served through the bunny.net CDN, which gzip/brotli-compresses at the edge.
// Pre-gzipped .gzip twins are served with Content-Encoding: gzip and cannot be processed
// by the CDN, so stop generating them. mod_deflate still compresses direct origin hits.
$GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'] = 0;
// utf8 in MySQL/MariaDB is utf8mb3 (max. 3 bytes) and rejects 4-byte characters like emoji.
// Set here because LocalConfiguration.php is a Deployer shared file and not deployed from the repo.
$GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['charset'] = 'utf8mb4';
$GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['tableoptions'] = [
'charset' => 'utf8mb4',
'collate' => 'utf8mb4_unicode_ci',
];
// suppress php deprecation warnings flooding typo3 logs
$GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] =
$GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] & ~E_DEPRECATED & ~E_USER_DEPRECATED;
/*
$GLOBALS['TYPO3_CONF_VARS']['BE']['cookieSameSite'] = 'lax';
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['oauth2_client'] = [
'providers' => [
'myep' => [
'label' => 'MyEP',
'iconIdentifier' => 'ep-logo',
'scopes' => [
\Waldhacker\Oauth2Client\Service\Oauth2ProviderManager::SCOPE_BACKEND,
],
'options' => [
'clientId' => '8df8f314ce17e8b716f86f9c24564746',
'clientSecret' => '7ff2532abc06f2b48438fc2e6e0278c8fdfb0e2de839889f4d5d17fd4a37cfffcca3bb19166331cc6ced462550611b33f08e9a3a993270d8c786a884c7fe935b',
'urlAuthorize' => 'https://myep-next-booking.ddev.site/authorize',
'urlAccessToken' => 'https://myep-next-booking.ddev.site/token',
'urlResourceOwnerDetails' => 'https://myep-next-booking.ddev.site/api/userinfo',
'scopes' => ['email', 'id', 'roles', 'profile'],
'scopeSeparator' => ' ',
],
],
],
];
*/
@@ -59,7 +59,6 @@ call_user_func(function() {
'label' => 'Reiseart', 'label' => 'Reiseart',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['Keine Zuordnung', 0], ['Keine Zuordnung', 0],
@@ -75,7 +74,6 @@ call_user_func(function() {
'label' => 'Angebot/Beispielangebot', 'label' => 'Angebot/Beispielangebot',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['Keine Zuordnung', 0], ['Keine Zuordnung', 0],
@@ -39,7 +39,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -54,7 +53,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -36,7 +36,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -51,7 +50,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -37,7 +37,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -52,7 +51,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -38,7 +38,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -53,7 +52,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -50,7 +50,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -65,7 +64,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -451,7 +449,6 @@ return [
'label' => 'Ansprechpartner', 'label' => 'Ansprechpartner',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epevents_domain_model_contact', 'foreign_table' => 'tx_epevents_domain_model_contact',
'foreign_table_where' => 'AND 1=1 order by name', 'foreign_table_where' => 'AND 1=1 order by name',
@@ -464,7 +461,6 @@ return [
'label' => 'Unterkunft', 'label' => 'Unterkunft',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epevents_domain_model_hotel', 'foreign_table' => 'tx_epevents_domain_model_hotel',
'foreign_table_where' => 'AND 1=1 order by name', 'foreign_table_where' => 'AND 1=1 order by name',
@@ -477,7 +473,6 @@ return [
'label' => 'Location', 'label' => 'Location',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epevents_domain_model_location', 'foreign_table' => 'tx_epevents_domain_model_location',
'foreign_table_where' => 'AND 1=1 order by name', 'foreign_table_where' => 'AND 1=1 order by name',
@@ -35,7 +35,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -50,7 +49,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -38,7 +38,6 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -53,13 +52,12 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
], ],
'foreign_table' => 'tx_epevents_domain_model_traveltype', 'foreign_table' => 'tx_epevents_domain_model_offer',
'foreign_table_where' => 'AND tx_epevents_domain_model_traveltype.pid=###CURRENT_PID### AND tx_epevents_domain_model_traveltype.sys_language_uid IN (-1,0)', 'foreign_table_where' => 'AND tx_epevents_domain_model_offer.pid=###CURRENT_PID### AND tx_epevents_domain_model_offer.sys_language_uid IN (-1,0)',
], ],
], ],
'l10n_diffsource' => [ 'l10n_diffsource' => [
@@ -4,6 +4,9 @@ import Vue from 'vue'
import $ from 'jquery' import $ from 'jquery'
import VueSession from 'vue-session' import VueSession from 'vue-session'
import 'lazysizes';
import 'lazysizes/plugins/attrchange/ls.attrchange';
const slick = require('slick-carousel'); const slick = require('slick-carousel');
const Sticky = require('sticky-js'); const Sticky = require('sticky-js');
const $window = $(window); const $window = $(window);
@@ -38,9 +38,9 @@
<div class="card card--no-form offerlist-item__card"> <div class="card card--no-form offerlist-item__card">
<div class="card__inner"> <div class="card__inner">
<picture> <picture>
<source media="(max-width: 512px)" :srcset="offer.teaserImageMobile"/> <source media="(max-width: 512px)" :data-srcset="offer.teaserImageMobile + ' 500w'"/>
<img class="card__image scale" <img class="card__image scale lazyload" data-sizes="auto"
:src="offer.teaserImage" alt="" loading="lazy"> :data-src="offer.teaserImage" alt="">
</picture> </picture>
<div class="card__caption"> <div class="card__caption">
<span class="card__subtitle" v-html="offer.name"></span> <span class="card__subtitle" v-html="offer.name"></span>
@@ -55,6 +55,7 @@
</template> </template>
<script> <script>
import axios from 'axios';
import $ from 'jquery'; import $ from 'jquery';
export default { export default {
@@ -81,21 +82,13 @@
load() { load() {
this.loading = true; this.loading = true;
sessionStorage.setItem('filterSettings', JSON.stringify(this.filterSettings)); sessionStorage.setItem('filterSettings', JSON.stringify(this.filterSettings));
fetch(this.endpointUri, { axios({
method: 'POST', url: this.endpointUri,
credentials: 'same-origin', method: 'post',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, data: $.param({'tx_epevents_ajax[filterSettings]': this.filterSettings})
body: $.param({'tx_epevents_ajax[filterSettings]': this.filterSettings})
}).then(response => { }).then(response => {
// fetch only rejects on network errors, so surface HTTP errors here this.offers = response.data.offers;
// to keep the previous behaviour of falling through to .catch() this.filterOptions = response.data.filterOptions;
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}).then(data => {
this.offers = data.offers;
this.filterOptions = data.filterOptions;
this.loading = false; this.loading = false;
}).catch(() => { }).catch(() => {
this.loading = false; this.loading = false;
@@ -1,10 +1,7 @@
@use "breakpoints";
@use "mixins";
.badge { .badge {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -30,15 +27,15 @@
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
line-height: 130%; line-height: 130%;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 32px; font-size: 32px;
} }
} }
.badge__outline { .badge__outline {
@include mixins.sectionFills(0.15); @include sectionFills(0.15);
} }
.badge__inner { .badge__inner {
@include mixins.sectionFills(); @include sectionFills();
} }
@@ -1,5 +1,3 @@
@use "mixins";
.banner { .banner {
position: relative; position: relative;
margin-bottom: 20px; margin-bottom: 20px;
@@ -10,7 +8,7 @@
min-height: 300px; min-height: 300px;
padding: 48px 0; padding: 48px 0;
color: white; color: white;
@include mixins.sectionBackgrounds(0.8); @include sectionBackgrounds(0.8);
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
@@ -1,20 +1,14 @@
@use "breakpoints";
@use "colors";
@use "fonts";
@use "mixins";
@use "variables";
html, html,
body { body {
height: 100%; height: 100%;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: fonts.$font-main, Sans-Serif; font-family: $font-main, Sans-Serif;
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 18px; font-size: 18px;
} }
@@ -47,13 +41,13 @@ html {
height: auto; height: auto;
margin-left: auto; margin-left: auto;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: none; display: none;
} }
} }
.navtoggle__icon { .navtoggle__icon {
fill: colors.$color-font-main; fill: $color-font-main;
transition: all 0.25s ease-in-out; transition: all 0.25s ease-in-out;
.mobilenav--open & { .mobilenav--open & {
@@ -68,7 +62,7 @@ html {
right: 2%; right: 2%;
width: 40px; width: 40px;
height: 40px; height: 40px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
opacity: 0.75; opacity: 0.75;
} }
@@ -82,12 +76,12 @@ a {
&.anchor { &.anchor {
display: block; display: block;
height: 0; height: 0;
padding-top: variables.$header-height-mobile; padding-top: $header-height-mobile;
margin-top: -(variables.$header-height-mobile); margin-top: -$header-height-mobile;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding-top: variables.$header-height-desktop; padding-top: $header-height-desktop;
margin-top: -(variables.$header-height-desktop); margin-top: -$header-height-desktop;
} }
} }
} }
@@ -132,7 +126,7 @@ ul {
left: 0; left: 0;
content: "\25CF"; content: "\25CF";
font-size: 18px; font-size: 18px;
@include mixins.sectionColoursFromParent(); @include sectionColoursFromParent();
} }
} }
} }
@@ -150,8 +144,8 @@ p {
hr { hr {
max-width: 25%; max-width: 25%;
margin: 32px auto 32px 0; margin: 32px auto 32px 0;
color: colors.$color-ruler; color: $color-ruler;
background-color: colors.$color-ruler; background-color: $color-ruler;
height: 1px; height: 1px;
border: none; border: none;
} }
@@ -167,7 +161,7 @@ h3,
} }
.headline { .headline {
@include mixins.sectionColoursFromParent(); @include sectionColoursFromParent();
&--icon { &--icon {
display: flex; display: flex;
@@ -192,7 +186,7 @@ h1,
text-transform: uppercase; text-transform: uppercase;
padding-bottom: 18px; padding-bottom: 18px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 32px; font-size: 32px;
padding-bottom: 24px; padding-bottom: 24px;
} }
@@ -206,11 +200,11 @@ h1,
display: block; display: block;
font-size: 16px; font-size: 16px;
line-height: 120%; line-height: 120%;
color: colors.$color-brand-primary; color: $color-brand-primary;
text-transform: none; text-transform: none;
@include mixins.sectionColoursFromParent(); @include sectionColoursFromParent();
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 24px; font-size: 24px;
} }
@@ -227,7 +221,7 @@ h2,
padding-bottom: 16px; padding-bottom: 16px;
font-weight: 400; font-weight: 400;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 24px; font-size: 24px;
padding-bottom: 24px; padding-bottom: 24px;
} }
@@ -237,7 +231,7 @@ h3 {
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 18px; font-size: 18px;
padding-bottom: 8px; padding-bottom: 8px;
} }
@@ -249,7 +243,7 @@ h3 {
height: auto; height: auto;
padding-right: 16px; padding-right: 16px;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
width: 96px; width: 96px;
} }
} }
@@ -1,6 +1,3 @@
@use "breakpoints";
@use "colors";
.box { .box {
width: 100%; width: 100%;
} }
@@ -16,7 +13,7 @@
color: white; color: white;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
background-color: colors.$color-brand-secondary; background-color: $color-brand-secondary;
border: none; border: none;
width: 100%; width: 100%;
font-size: 20px; font-size: 20px;
@@ -26,18 +23,18 @@
.box__content { .box__content {
padding: 32px 16px; padding: 32px 16px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
} }
.box__form { .box__form {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
fill: colors.$color-brand-primary; fill: $color-brand-primary;
} }
&--top { &--top {
@@ -55,7 +52,7 @@
font-size: 16px; font-size: 16px;
line-height: 32px; line-height: 32px;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
font-size: 20px; font-size: 20px;
line-height: 32px; line-height: 32px;
@@ -69,7 +66,7 @@
.box__nav { .box__nav {
margin-top: -32px; margin-top: -32px;
padding-bottom: 32px; padding-bottom: 32px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
} }
.box__nav__item { .box__nav__item {
@@ -81,7 +78,7 @@
&--active, &--active,
&:hover { &:hover {
background-color: colors.$color-brand-light; background-color: $color-brand-light;
} }
} }
@@ -1,9 +1,6 @@
@use "breakpoints";
@use "colors";
.brands { .brands {
padding-top: 32px; padding-top: 32px;
border-top: 1px solid colors.$color-divider; border-top: 1px solid $color-divider;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
@@ -12,7 +9,7 @@
.brands__item { .brands__item {
width: 50%; width: 50%;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 25%; width: 25%;
} }
} }
@@ -1,9 +1,7 @@
@use "variables"; $mq-breakpoints: (
mobile: $viewport-width-mobile,
@forward "sass-mq" with ( tablet: $viewport-width-tablet,
$breakpoints: ( desktop: $viewport-width-desktop
mobile: variables.$viewport-width-mobile,
tablet: variables.$viewport-width-tablet,
desktop: variables.$viewport-width-desktop
)
); );
@import "~sass-mq";
@@ -1,7 +1,3 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.button { .button {
display: inline-block; display: inline-block;
font-weight: 700; font-weight: 700;
@@ -9,7 +5,7 @@
&--default { &--default {
display: inline-block; display: inline-block;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
padding: 4px 16px; padding: 4px 16px;
} }
@@ -17,7 +13,7 @@
&--page-header { &--page-header {
display: block; display: block;
min-width: 186px; min-width: 186px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
font-size: 18px; font-size: 18px;
padding: 0 32px 0 24px; padding: 0 32px 0 24px;
@@ -30,14 +26,14 @@
display: block; display: block;
min-width: 220px; min-width: 220px;
text-align: center; text-align: center;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
@include mixins.sectionBackgroundsFromParent(); @include sectionBackgroundsFromParent();
color: white; color: white;
line-height: 48px; line-height: 48px;
border: none; border: none;
outline: none; outline: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
box-shadow: 8px 8px 8px rgba(black, 0.2); box-shadow: 8px 8px 8px rgba(black, 0.2);
font-size: 24px; font-size: 24px;
} }
@@ -47,7 +43,7 @@
width: 100%; width: 100%;
display: inline-block; display: inline-block;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: auto; width: auto;
} }
} }
@@ -60,7 +56,7 @@
&:hover { &:hover {
background: white; background: white;
color: colors.$color-font-main; color: $color-font-main;
} }
} }
@@ -69,7 +65,7 @@
} }
&--icon { &--icon {
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -1,8 +1,5 @@
@use "breakpoints";
@use "mixins";
.card { .card {
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
&:not(.card--no-form) { &:not(.card--no-form) {
padding-bottom: 48px; padding-bottom: 48px;
} }
@@ -23,12 +20,12 @@
padding: 16px; padding: 16px;
color: white; color: white;
text-transform: uppercase; text-transform: uppercase;
@include mixins.sectionBackgrounds(0.75); @include sectionBackgrounds(0.75);
transition: background-color 0.25s ease-in-out; transition: background-color 0.25s ease-in-out;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
.card:hover & { .card:hover & {
@include mixins.sectionBackgrounds(); @include sectionBackgrounds();
} }
} }
} }
@@ -36,7 +33,7 @@
.card__form { .card__form {
display: none; display: none;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
display: block; display: block;
opacity: 0; opacity: 0;
transition: opacity 0.25s ease-in-out; transition: opacity 0.25s ease-in-out;
@@ -53,7 +50,7 @@
} }
polygon { polygon {
@include mixins.sectionFills(); @include sectionFills();
} }
} }
@@ -1,25 +1,21 @@
@use "colors";
@use "mixins";
@use "variables";
.carousel { .carousel {
&--background { &--background {
background-color: colors.$color-background; background-color: $color-background;
padding-bottom: 48px; padding-bottom: 48px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@include mixins.sectionBackgrounds(0.25); @include sectionBackgrounds(0.25);
} }
.carousel__outer { .carousel__outer {
@include mixins.container(variables.$viewport-width-max); @include container($viewport-width-max);
position: relative; position: relative;
} }
.carousel__inner { .carousel__inner {
@include mixins.container(variables.$content-width-max); @include container($content-width-max);
} }
.carousel__item { .carousel__item {
@@ -40,7 +36,7 @@
height: 32px; height: 32px;
fill: white; fill: white;
@include mixins.sectionColoursFromParent; @include sectionColoursFromParent;
&--prev { &--prev {
left: -5px; left: -5px;
@@ -63,7 +59,7 @@
} }
.testimonials & { .testimonials & {
fill: colors.$color-brand-primary; fill: $color-brand-primary;
} }
} }
@@ -1,10 +1,8 @@
@use "sass:color";
//$color-brand-primary: #0084d4; //$color-brand-primary: #0084d4;
$color-brand-primary: #19a0d2; $color-brand-primary: #19a0d2;
$color-brand-secondary: #1e314b; $color-brand-secondary: #1e314b;
$color-brand-light: #8CCFE8; $color-brand-light: #8CCFE8;
$color-background: color.adjust(#dbdee2, $lightness: 5%); $color-background: lighten(#dbdee2, 5%);
$color-font-main: #1e314b; $color-font-main: #1e314b;
$color-divider: rgba($color-background, 0.8); $color-divider: rgba($color-background, 0.8);
@@ -1,6 +1,3 @@
@use "breakpoints";
@use "colors";
.configurator { .configurator {
background: url(../images/bg_configurator.jpg) no-repeat top left; background: url(../images/bg_configurator.jpg) no-repeat top left;
background-size: cover; background-size: cover;
@@ -42,7 +39,7 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: rgba(colors.$color-brand-secondary, 0.85); background-color: rgba($color-brand-secondary, 0.85);
color: white; color: white;
} }
@@ -54,7 +51,7 @@
display: none; display: none;
color: white; color: white;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
display: block; display: block;
} }
} }
@@ -86,11 +83,11 @@
text-decoration: none; text-decoration: none;
text-transform: uppercase; text-transform: uppercase;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
align-items: center; align-items: center;
} }
} }
@@ -101,12 +98,12 @@
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 700; font-weight: 700;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 30%; width: 30%;
margin-bottom: 0; margin-bottom: 0;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 20%; width: 20%;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -116,11 +113,11 @@
width: 100%; width: 100%;
padding-right: 40px; padding-right: 40px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 70%; width: 70%;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 80%; width: 80%;
} }
} }
@@ -134,11 +131,11 @@
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 16px; font-size: 16px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
margin-bottom: 0; margin-bottom: 0;
} }
} }
@@ -154,13 +151,13 @@
text-decoration: none; text-decoration: none;
text-align: center; text-align: center;
color: white; color: white;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
border-radius: 16px; border-radius: 16px;
font-family: "fontello"; font-family: "fontello";
&.closed { &.closed {
background-color: white; background-color: white;
color: colors.$color-brand-primary; color: $color-brand-primary;
&:before { &:before {
content: '\e800'; content: '\e800';
@@ -207,7 +204,7 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
width: 50%; width: 50%;
.active & { .active & {
@@ -215,7 +212,7 @@
} }
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 25%; width: 25%;
.active & { .active & {
@@ -243,10 +240,10 @@
} }
.selected &:after { .selected &:after {
background-color: rgba(colors.$color-brand-secondary, 0.75); background-color: rgba($color-brand-secondary, 0.75);
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
&:hover:after { &:hover:after {
content: ""; content: "";
width: 100%; width: 100%;
@@ -254,7 +251,7 @@
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
background-color: rgba(colors.$color-brand-primary, 0.5); background-color: rgba($color-brand-primary, 0.5);
} }
} }
@@ -263,12 +260,12 @@
background-position: center 32px; background-position: center 32px;
background-size: 96px; background-size: 96px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
background-position: center 16px; background-position: center 16px;
background-size: 128px; background-size: 128px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
background-position: center 8px; background-position: center 8px;
background-size: 96px; background-size: 96px;
} }
@@ -385,7 +382,7 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
@@ -394,7 +391,7 @@
width: 100%; width: 100%;
padding-bottom: 16px; padding-bottom: 16px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 66.6666%; width: 66.6666%;
padding-right: 32px; padding-right: 32px;
} }
@@ -404,7 +401,7 @@
width: 100%; width: 100%;
padding-bottom: 16px; padding-bottom: 16px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -459,7 +456,7 @@
line-height: 32px; line-height: 32px;
text-align: center; text-align: center;
background-color: white; background-color: white;
color: colors.$color-brand-primary; color: $color-brand-primary;
border-radius: 16px; border-radius: 16px;
font-family: 'fontello'; font-family: 'fontello';
@@ -1,6 +1,3 @@
// .headline* live in _base.scss; @extend needs that module loaded explicitly.
@use "base";
.cookiefirst { .cookiefirst {
h2 { h2 {
@extend .headline; @extend .headline;
@@ -1,14 +1,10 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.form { .form {
margin: 0; margin: 0;
padding: 0; padding: 0;
position: relative; position: relative;
max-width: 100%; max-width: 100%;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
&:not(&--horizontal) { &:not(&--horizontal) {
max-width: 75%; max-width: 75%;
} }
@@ -20,7 +16,7 @@
&--horizontal { &--horizontal {
width: 100%; width: 100%;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -46,7 +42,7 @@
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: colors.$color-brand-light; background-color: $color-brand-light;
color: white; color: white;
border-radius: 8px; border-radius: 8px;
} }
@@ -54,7 +50,7 @@
&--mobile { &--mobile {
display: block; display: block;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: none; display: none;
} }
} }
@@ -62,7 +58,7 @@
&--tablet { &--tablet {
display: none; display: none;
@include breakpoints.mq($from: tablet, $until: desktop) { @include mq($from: tablet, $until: desktop) {
display: block; display: block;
} }
} }
@@ -70,7 +66,7 @@
&--desktop { &--desktop {
display: none; display: none;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
display: block; display: block;
} }
} }
@@ -83,7 +79,7 @@
padding-top: 20px; padding-top: 20px;
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -92,20 +88,20 @@
.form__item__error { .form__item__error {
padding: 8px; padding: 8px;
font-size: 90%; font-size: 90%;
color: colors.$color-formerror; color: $color-formerror;
} }
.form__field { .form__field {
@include mixins.nooutline; @include nooutline;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
width: 100%; width: 100%;
padding: 8px 16px; padding: 8px 16px;
color: white; color: white;
background-color: colors.$color-brand-light; background-color: $color-brand-light;
.form--box & { .form--box & {
background-color: colors.$color-brand-light; background-color: $color-brand-light;
color: white; color: white;
} }
@@ -118,10 +114,10 @@
display: block; display: block;
padding: 8px; padding: 8px;
width: 100%; width: 100%;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 50%; width: 50%;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 25%; width: 25%;
} }
} }
@@ -131,7 +127,7 @@
} }
.has-errors & { .has-errors & {
border: 1px solid colors.$color-formerror; border: 1px solid $color-formerror;
} }
} }
@@ -162,7 +158,7 @@
display: block; display: block;
width: 100%; width: 100%;
color: white; color: white;
background-color: colors.$color-brand-secondary; background-color: $color-brand-secondary;
border: none; border: none;
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
@@ -170,7 +166,7 @@
padding: 8px 32px; padding: 8px 32px;
margin-bottom: 16px; margin-bottom: 16px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: inline-block; display: inline-block;
width: auto; width: auto;
margin-bottom: 0; margin-bottom: 0;
@@ -193,10 +189,10 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: rgba(colors.$color-divider, 0.75); background-color: rgba($color-divider, 0.75);
.form--box & { .form--box & {
background-color: rgba(colors.$color-brand-primary, 0.75); background-color: rgba($color-brand-primary, 0.75);
} }
} }
@@ -1,10 +1,7 @@
@use "breakpoints";
@use "colors";
.helpernav { .helpernav {
display: none; display: none;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
display: block; display: block;
position: relative; position: relative;
} }
@@ -27,7 +24,7 @@
z-index: 10; z-index: 10;
min-width: 300px; min-width: 300px;
padding: 16px 32px; padding: 16px 32px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
} }
.helpernav__item { .helpernav__item {
@@ -35,7 +32,7 @@
&:hover:not(.helpernav__item--disabled) { &:hover:not(.helpernav__item--disabled) {
& > .helpernav__link { & > .helpernav__link {
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
} }
& > .helpernav__subnav { & > .helpernav__subnav {
@@ -1,16 +1,11 @@
@use "breakpoints";
@use "colors";
@use "mixins";
@use "variables";
.herounit { .herounit {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: variables.$viewport-width-max; max-width: $viewport-width-max;
margin: 0 auto; margin: 0 auto;
&--indent { &--indent {
max-width: variables.$content-width-max - 16px; max-width: $content-width-max - 16px;
} }
} }
@@ -21,22 +16,22 @@
height: 240px; height: 240px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
height: 384px; height: 384px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
height: 420px; height: 420px;
} }
.herounit--large & { .herounit--large & {
height: 300px; height: 300px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
height: 480px; height: 480px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
height: 700px; height: 700px;
} }
} }
@@ -44,7 +39,7 @@
.herounit--indent & { .herounit--indent & {
height: 320px; height: 320px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
height: 480px; height: 480px;
} }
} }
@@ -53,7 +48,7 @@
.herounit__mask { .herounit__mask {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
top: 0; top: 0;
@@ -72,9 +67,9 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 32px; padding: 32px;
background-color: rgba(colors.$color-brand-secondary, 0.2); background-color: rgba($color-brand-secondary, 0.2);
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
background-color: transparent; background-color: transparent;
padding: 64px 16px 0 96px; padding: 64px 16px 0 96px;
max-width: 45%; max-width: 45%;
@@ -88,7 +83,7 @@
font-size: 32px; font-size: 32px;
line-height: 95%; line-height: 95%;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
font-size: 48px; font-size: 48px;
} }
@@ -106,10 +101,10 @@
span { span {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: colors.$color-brand-primary; color: $color-brand-primary;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
font-size: 56px; font-size: 56px;
} }
} }
@@ -125,7 +120,7 @@
color: white; color: white;
text-transform: none; text-transform: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 20px; font-size: 20px;
} }
@@ -133,12 +128,12 @@
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 24px; font-size: 24px;
padding-bottom: 48px; padding-bottom: 48px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
font-size: 32px; font-size: 32px;
padding-bottom: 96px; padding-bottom: 96px;
} }
@@ -151,7 +146,7 @@
line-height: 120%; line-height: 120%;
color: white; color: white;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -159,7 +154,7 @@
.herounit__icon { .herounit__icon {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
@@ -178,11 +173,11 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
top: 85%; top: 85%;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@@ -195,34 +190,34 @@
height: 48px; height: 48px;
border: none; border: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
} }
& > polygon { & > polygon {
fill: colors.$color-brand-primary; fill: $color-brand-primary;
@include mixins.sectionFills(); @include sectionFills();
} }
} }
#gradient__from { #gradient__from {
stop-color: colors.$color-brand-secondary; stop-color: $color-brand-secondary;
} }
#gradient__to { #gradient__to {
.section-default & { .section-default & {
stop-color: colors.$color-brand-primary; stop-color: $color-brand-primary;
} }
.section-incentives & { .section-incentives & {
stop-color: colors.$color-incentives; stop-color: $color-incentives;
} }
.section-teambuilding & { .section-teambuilding & {
stop-color: colors.$color-teambuilding; stop-color: $color-teambuilding;
} }
.secolor-meeting & { .secolor-meeting & {
stop-color: colors.$color-meetings; stop-color: $color-meetings;
} }
.secolor-events & { .secolor-events & {
stop-color: colors.$color-events; stop-color: $color-events;
} }
} }
@@ -1,12 +1,9 @@
@use "breakpoints";
@use "colors";
.iconbar { .iconbar {
@apply hidden lg:block fixed z-100 top-[30vh] right-0 overflow-hidden; @apply hidden lg:block fixed z-100 top-[30vh] right-0 overflow-hidden;
} }
.iconbar { .iconbar {
display: none; display: none;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
display: block; display: block;
} }
position: fixed; position: fixed;
@@ -23,7 +20,7 @@
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
background-color: colors.$color-brand-light; background-color: $color-brand-light;
margin-bottom: 2px; margin-bottom: 2px;
&:hover { &:hover {
@@ -33,7 +30,7 @@
.iconbar__icon { .iconbar__icon {
padding: 4px; padding: 4px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
svg { svg {
display: block; display: block;
@@ -1,6 +1,3 @@
@use "breakpoints";
@use "mixins";
.iconnav { .iconnav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -24,7 +21,7 @@
opacity: 1; opacity: 1;
} }
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
width: 25%; width: 25%;
} }
} }
@@ -39,5 +36,5 @@
.iconnav__label { .iconnav__label {
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
@include mixins.sectionColours(); @include sectionColours();
} }
@@ -1,10 +1,7 @@
@use "breakpoints";
@use "mixins";
main { main {
padding-top: 96px; padding-top: 96px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding-top: 140px; padding-top: 140px;
} }
} }
@@ -13,7 +10,7 @@ main {
width: 100%; width: 100%;
padding-top: 32px; padding-top: 32px;
padding-bottom: 32px; padding-bottom: 32px;
@include mixins.clearfix(); @include clearfix();
&.nopad { &.nopad {
&--bottom { &--bottom {
@@ -26,18 +23,18 @@ main {
} }
.content__title { .content__title {
@include mixins.container(); @include container();
text-align: center; text-align: center;
} }
.content__inner { .content__inner {
@include mixins.container(); @include container();
.content--has-sidebar & { .content--has-sidebar & {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
@@ -48,40 +45,40 @@ main {
padding: 0 0 32px; padding: 0 0 32px;
overflow: hidden; overflow: hidden;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding: 0; padding: 0;
} }
} }
.content__plain { .content__plain {
@include mixins.clearfix(); @include clearfix();
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding-right: 96px; padding-right: 96px;
} }
} }
.content__background { .content__background {
@include mixins.clearfix(); @include clearfix();
@include mixins.sectionBackgrounds(0.15); @include sectionBackgrounds(0.15);
padding: 24px 48px 24px 32px; padding: 24px 48px 24px 32px;
} }
.content__float { .content__float {
@include mixins.clearfix(); @include clearfix();
} }
.content__sidebar { .content__sidebar {
width: 100%; width: 100%;
padding: 32px 0; padding: 32px 0;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
flex: 0 0 320px; flex: 0 0 320px;
padding: 0 0 0 32px; padding: 0 0 0 32px;
} }
} }
.content__form { .content__form {
@include mixins.sectionFills(0.15); @include sectionFills(0.15);
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;
@@ -96,7 +93,7 @@ main {
.desktop-only { .desktop-only {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -1,6 +1,4 @@
@use "sass:math"; @use "sass:math";
@use "variables" as *;
@use "colors" as *;
@mixin clearfix { @mixin clearfix {
&::after { &::after {
@@ -1,6 +1,3 @@
@use "breakpoints";
@use "colors";
.mobilenav { .mobilenav {
position: fixed; position: fixed;
z-index: 50; z-index: 50;
@@ -10,10 +7,10 @@
height: 0; height: 0;
padding-top: 96px; padding-top: 96px;
overflow: hidden; overflow: hidden;
background: rgba(colors.$color-brand-secondary, 0.9); background: rgba($color-brand-secondary, 0.9);
transition: height 0.25s ease-in-out; transition: height 0.25s ease-in-out;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
display: none; display: none;
} }
@@ -1,5 +1,3 @@
@use "breakpoints";
.news-entry { .news-entry {
width: 100%; width: 100%;
&:not(:last-child) { &:not(:last-child) {
@@ -15,7 +13,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
flex-direction: row; flex-direction: row;
} }
} }
@@ -24,7 +22,7 @@
width: 100%; width: 100%;
margin-bottom: 8px; margin-bottom: 8px;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 50%; width: 50%;
} }
} }
@@ -41,7 +39,7 @@
.news-entry__teaser { .news-entry__teaser {
width: 100%; width: 100%;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
padding-left: 24px; padding-left: 24px;
width: 50%; width: 50%;
} }
@@ -1,5 +1,3 @@
@use "breakpoints";
.offerlist { .offerlist {
position: relative; position: relative;
} }
@@ -15,7 +13,7 @@
} }
.offerlist__teasers { .offerlist__teasers {
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -26,13 +24,13 @@
text-decoration: none; text-decoration: none;
margin-bottom: 32px; margin-bottom: 32px;
display: block; display: block;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
display: flex; display: flex;
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 50%; width: 50%;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -1,30 +1,25 @@
@use "breakpoints";
@use "colors";
@use "mixins";
@use "variables";
.page-footer { .page-footer {
width: 100%; width: 100%;
background: linear-gradient(to right, colors.$color-brand-secondary, colors.$color-brand-primary); background: linear-gradient(to right, $color-brand-secondary, $color-brand-primary);
color: white; color: white;
padding-top: 16px; padding-top: 16px;
padding-bottom: 16px; padding-bottom: 16px;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
padding-top: 48px; padding-top: 48px;
padding-bottom: 48px; padding-bottom: 48px;
} }
} }
.page-footer__inner { .page-footer__inner {
@include mixins.container(variables.$content-width-max); @include container($content-width-max);
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
border-bottom: 1px solid white; border-bottom: 1px solid white;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding-bottom: 48px; padding-bottom: 48px;
} }
} }
@@ -38,14 +33,14 @@
text-align: left; text-align: left;
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 20%; width: 20%;
} }
&--hide-mobile { &--hide-mobile {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -65,14 +60,14 @@
} }
.page-footer__bottom { .page-footer__bottom {
@include mixins.container(variables.$content-width-max); @include container($content-width-max);
padding-top: 32px; padding-top: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-around; justify-content: space-around;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
} }
@@ -81,7 +76,7 @@
.page-footer__bottom__column { .page-footer__bottom__column {
width: 100%; width: 100%;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -91,7 +86,7 @@
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
justify-content: flex-start; justify-content: flex-start;
} }
} }
@@ -111,7 +106,7 @@
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
text-align: left; text-align: left;
} }
} }
@@ -120,7 +115,7 @@
text-align: center; text-align: center;
padding-top: 32px; padding-top: 32px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
text-align: right; text-align: right;
padding-top: 0; padding-top: 0;
} }
@@ -143,7 +138,7 @@
text-align: center; text-align: center;
padding-top: 32px; padding-top: 32px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
text-align: left; text-align: left;
padding-top: 0; padding-top: 0;
} }
@@ -1,7 +1,3 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.page-header { .page-header {
width: 100%; width: 100%;
position: fixed; position: fixed;
@@ -9,28 +5,28 @@
left: 0; left: 0;
z-index: 1000; z-index: 1000;
background: white; background: white;
box-shadow: 0 2px 2px rgba(colors.$color-background, 0.5); box-shadow: 0 2px 2px rgba($color-background, 0.5);
} }
.page-header__bar { .page-header__bar {
background: linear-gradient(to right, colors.$color-brand-primary 50%, colors.$color-brand-secondary); background: linear-gradient(to right, $color-brand-primary 50%, $color-brand-secondary);
} }
.page-header__bar__main { .page-header__bar__main {
@include mixins.container(); @include container();
color: white; color: white;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
justify-content: space-between; justify-content: space-between;
} }
} }
.page-header__bar__search { .page-header__bar__search {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
width: 50%; width: 50%;
} }
@@ -41,7 +37,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 50%; width: 50%;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -57,14 +53,14 @@
.page-header__bar__divider { .page-header__bar__divider {
display: none; display: none;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
display: inline-block; display: inline-block;
padding: 12px 8px; padding: 12px 8px;
} }
} }
.page-header__main { .page-header__main {
@include mixins.container(); @include container();
padding: 16px; padding: 16px;
display: flex; display: flex;
display: -ms-flexbox; display: -ms-flexbox;
@@ -72,11 +68,11 @@
align-items: center; align-items: center;
-ms-flex-align: center; -ms-flex-align: center;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding: 32px 16px 24px; padding: 32px 16px 24px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
-ms-flex-align: end; -ms-flex-align: end;
@@ -102,7 +98,7 @@
.page-header__sitenav { .page-header__sitenav {
display: none; display: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -110,7 +106,7 @@
.page-header__button { .page-header__button {
display: none; display: none;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
position: relative; position: relative;
display: block; display: block;
min-width: 220px; min-width: 220px;
@@ -132,6 +128,6 @@
border: none; border: none;
& > polygon { & > polygon {
fill: colors.$color-brand-primary; fill: $color-brand-primary;
} }
} }
@@ -1,5 +1,3 @@
@use "colors";
.paginator { .paginator {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -12,7 +10,7 @@
.paginator__item { .paginator__item {
display: inline-block; display: inline-block;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
padding: 4px; padding: 4px;
text-decoration: none; text-decoration: none;
@@ -21,8 +19,8 @@
} }
&--current { &--current {
border: 1px solid colors.$color-brand-primary; border: 1px solid $color-brand-primary;
background-color: white; background-color: white;
color: colors.$color-brand-primary; color: $color-brand-primary;
} }
} }
@@ -1,27 +1,24 @@
@use "breakpoints";
@use "mixins";
.searchresult {} .searchresult {}
.searchresult__info { .searchresult__info {
@include mixins.container(); @include container();
} }
.searchresult-item { .searchresult-item {
text-decoration: none; text-decoration: none;
margin-bottom: 32px; margin-bottom: 32px;
display: block; display: block;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
display: flex; display: flex;
} }
} }
.searchresult-item__card { .searchresult-item__card {
width: 100%; width: 100%;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
width: 240px; width: 240px;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -30,8 +27,8 @@
margin: 0 8px; margin: 0 8px;
padding: 32px; padding: 32px;
color: white; color: white;
@include mixins.sectionBackgrounds(); @include sectionBackgrounds();
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
@@ -1,8 +1,5 @@
@use "breakpoints";
@use "mixins";
.sitenav { .sitenav {
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
padding-right: 64px; padding-right: 64px;
} }
} }
@@ -28,7 +25,7 @@
&--active, &--active,
&:hover { &:hover {
border-bottom: 3px solid; border-bottom: 3px solid;
@include mixins.sectionColours(); @include sectionColours();
} }
} }
@@ -1,5 +1,3 @@
@use "breakpoints";
.slider {} .slider {}
.slider__arrow { .slider__arrow {
@@ -40,7 +38,7 @@
top: 30%; top: 30%;
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
@@ -1,14 +1,11 @@
@use "breakpoints";
@use "mixins";
.statement { .statement {
@include mixins.container(); @include container();
padding-top: 32px; padding-top: 32px;
padding-bottom: 48px; padding-bottom: 48px;
} }
.statement__inner { .statement__inner {
@include mixins.sectionBackgrounds(); @include sectionBackgrounds();
box-shadow: 0 5px 10px rgba(black, 0.2); box-shadow: 0 5px 10px rgba(black, 0.2);
text-align: center; text-align: center;
padding-top: 48px; padding-top: 48px;
@@ -21,7 +18,7 @@
font-size: 24px; font-size: 24px;
line-height: 105%; line-height: 105%;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 48px; font-size: 48px;
} }
} }
@@ -1,6 +1,3 @@
@use "breakpoints";
@use "colors";
.team { .team {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -15,11 +12,11 @@
max-width: 400px; max-width: 400px;
padding: 0 16px 64px; padding: 0 16px 64px;
@include breakpoints.mq($until: desktop) { @include mq($until: desktop) {
margin: 0 auto; margin: 0 auto;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 50%; width: 50%;
max-width: 100%; max-width: 100%;
} }
@@ -60,10 +57,10 @@
.team__position { .team__position {
display: block; display: block;
color: colors.$color-brand-primary; color: $color-brand-primary;
} }
.team__contact__link { .team__contact__link {
text-decoration: none; text-decoration: none;
color: colors.$color-brand-primary; color: $color-brand-primary;
} }
@@ -1,15 +1,12 @@
@use "breakpoints";
@use "mixins";
.teasers { .teasers {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@include mixins.sectionBackgrounds(0.15); @include sectionBackgrounds(0.15);
margin: 0 -16px 0; margin: 0 -16px 0;
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
flex-direction: row; flex-direction: row;
} }
} }
@@ -22,15 +19,15 @@
cursor: pointer; cursor: pointer;
} }
@include breakpoints.mq($until: tablet) { @include mq($until: tablet) {
padding-bottom: 16px; padding-bottom: 16px;
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 50%; width: 50%;
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -43,12 +40,12 @@
} }
.teaser__caption { .teaser__caption {
@include mixins.sectionBackgroundsFromParent(); @include sectionBackgroundsFromParent();
padding: 16px 16px 0; padding: 16px 16px 0;
} }
.teaser__form { .teaser__form {
@include mixins.sectionFills(); @include sectionFills();
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;
@@ -1,20 +1,17 @@
@use "breakpoints";
@use "colors";
.testimonial { .testimonial {
width: 100%; width: 100%;
padding: 0 32px; padding: 0 32px;
text-align: center; text-align: center;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 80%; font-size: 80%;
width: 50%; width: 50%;
&:not(:last-child) { &:not(:last-child) {
border-right: 1px solid colors.$color-divider; border-right: 1px solid $color-divider;
} }
} }
@include breakpoints.mq($from: desktop) { @include mq($from: desktop) {
width: 25%; width: 25%;
} }
} }
@@ -1,7 +1,3 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.tiles { .tiles {
padding-bottom: 0; padding-bottom: 0;
} }
@@ -19,7 +15,7 @@
padding: 16px; padding: 16px;
text-decoration: none; text-decoration: none;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 50%; width: 50%;
padding: 16px; padding: 16px;
} }
@@ -30,12 +26,12 @@
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@include breakpoints.mq($from: mobile) { @include mq($from: mobile) {
min-height: 250px; min-height: 250px;
padding: 32px; padding: 32px;
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding: 32px 32px 0; padding: 32px 32px 0;
min-height: 375px; min-height: 375px;
} }
@@ -46,33 +42,33 @@
color: white; color: white;
text-align: center; text-align: center;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
width: 70%; width: 70%;
margin: 0 auto; margin: 0 auto;
} }
} }
.tile__caption__inner { .tile__caption__inner {
@include mixins.sectionBackgrounds(0.75); @include sectionBackgrounds(0.75);
width: 100%; width: 100%;
padding: 24px 0; padding: 24px 0;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
padding: 32px 0; padding: 32px 0;
.tile:hover .section-incentives & { .tile:hover .section-incentives & {
background-color: colors.$color-incentives; background-color: $color-incentives;
} }
.tile:hover .section-teambuilding & { .tile:hover .section-teambuilding & {
background-color: colors.$color-teambuilding; background-color: $color-teambuilding;
} }
.tile:hover .section-meetings & { .tile:hover .section-meetings & {
background-color: colors.$color-meetings; background-color: $color-meetings;
} }
.tile:hover .section-events & { .tile:hover .section-events & {
background-color: colors.$color-events; background-color: $color-events;
} }
} }
} }
@@ -81,7 +77,7 @@
display: none; display: none;
padding: 16px 32px 0; padding: 16px 32px 0;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
.tile:hover & { .tile:hover & {
display: block; display: block;
} }
@@ -95,10 +91,10 @@
margin-top: -1px; margin-top: -1px;
polygon { polygon {
@include mixins.sectionFills(); @include sectionFills();
} }
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
.tile:hover & { .tile:hover & {
display: block; display: block;
} }
@@ -118,7 +114,7 @@
font-weight: 700; font-weight: 700;
min-height: 54px; min-height: 54px;
@include breakpoints.mq($from: tablet) { @include mq($from: tablet) {
font-size: 24px; font-size: 24px;
} }
} }
@@ -1,7 +1,5 @@
@use "mixins";
.video { .video {
@include mixins.container(); @include container();
padding-top: 32px; padding-top: 32px;
padding-bottom: 32px; padding-bottom: 32px;
} }
@@ -1,39 +1,39 @@
@use "variables"; @import "variables";
@use "mixins"; @import "mixins";
@use "breakpoints"; @import "breakpoints";
@use "colors"; @import "colors";
@use "fonts"; @import "fonts";
@use "base"; @import "base";
@use "buttons"; @import "buttons";
@use "page-header"; @import "page-header";
@use "page-footer"; @import "page-footer";
@use "layout"; @import "layout";
@use "sitenav"; @import "sitenav";
@use "helpernav"; @import "helpernav";
@use "mobilenav"; @import "mobilenav";
@use "herounit"; @import "herounit";
@use "carousel"; @import "carousel";
@use "slider"; @import "slider";
@use "configurator"; @import "configurator";
@use "statement"; @import "statement";
@use "testimonial"; @import "testimonial";
@use "brands"; @import "brands";
@use "iconbar"; @import "iconbar";
@use "tiles"; @import "tiles";
@use "card"; @import "card";
@use "box"; @import "box";
@use "banner"; @import "banner";
@use "iconnav"; @import "iconnav";
@use "forms"; @import "forms";
@use "teasers"; @import "teasers";
@use "badge"; @import "badge";
@use "team"; @import "team";
@use "search"; @import "search";
@use "offerlist"; @import "offerlist";
@use "paginator"; @import "paginator";
@use "news"; @import "news";
@use "datepicker"; @import "datepicker";
@use "cookiefirst"; @import "cookiefirst";
@use "video"; @import "video";
@@ -1,14 +1,18 @@
@use "mixins"; @import "mixins";
@use "colors"; @import "colors";
@font-face { @font-face {
font-family: 'Cairo'; font-family: 'Cairo';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('../fonts/cairo-v2-latin-ext_latin-regular.eot'); /* IE9 Compat Modes */
src: local('Cairo'), local('Cairo-Regular'), src: local('Cairo'), local('Cairo-Regular'),
url('../fonts/cairo-v2-latin-ext_latin-regular.woff2') format('woff2'), url('../fonts/cairo-v2-latin-ext_latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('../fonts/cairo-v2-latin-ext_latin-regular.woff') format('woff'); url('../fonts/cairo-v2-latin-ext_latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('../fonts/cairo-v2-latin-ext_latin-regular.woff') format('woff'), /* Modern Browsers */
url('../fonts/cairo-v2-latin-ext_latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('../fonts/cairo-v2-latin-ext_latin-regular.svg#Cairo') format('svg'); /* Legacy iOS */
} }
@font-face { @font-face {
@@ -16,9 +20,13 @@
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('../fonts/cairo-v2-latin-ext_latin-700.eot'); /* IE9 Compat Modes */
src: local('Cairo Bold'), local('Cairo-Bold'), src: local('Cairo Bold'), local('Cairo-Bold'),
url('../fonts/cairo-v2-latin-ext_latin-700.woff2') format('woff2'), url('../fonts/cairo-v2-latin-ext_latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('../fonts/cairo-v2-latin-ext_latin-700.woff') format('woff'); url('../fonts/cairo-v2-latin-ext_latin-700.woff2') format('woff2'), /* Super Modern Browsers */
url('../fonts/cairo-v2-latin-ext_latin-700.woff') format('woff'), /* Modern Browsers */
url('../fonts/cairo-v2-latin-ext_latin-700.ttf') format('truetype'), /* Safari, Android, iOS */
url('../fonts/cairo-v2-latin-ext_latin-700.svg#Cairo') format('svg'); /* Legacy iOS */
} }
$font-main: 'Cairo'; $font-main: 'Cairo';
@@ -26,8 +34,12 @@ $font-main: 'Cairo';
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
font-display: swap; font-display: swap;
src: url('../fonts/fontello.woff2?39986861') format('woff2'), src: url('../fonts/fontello.eot?39986861');
url('../fonts/fontello.woff?39986861') format('woff'); src: url('../fonts/fontello.eot?39986861#iefix') format('embedded-opentype'),
url('../fonts/fontello.woff2?39986861') format('woff2'),
url('../fonts/fontello.woff?39986861') format('woff'),
url('../fonts/fontello.ttf?39986861') format('truetype'),
url('../fonts/fontello.svg?39986861#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -111,7 +123,7 @@ ul {
left: 0; left: 0;
content: "\25CF"; content: "\25CF";
font-size: 18px; font-size: 18px;
@include mixins.sectionColoursFromParent(); @include sectionColoursFromParent();
} }
} }
} }
@@ -129,8 +141,8 @@ p {
hr { hr {
max-width: 25%; max-width: 25%;
margin: 32px auto 32px 0; margin: 32px auto 32px 0;
color: colors.$color-ruler; color: $color-ruler;
background-color: colors.$color-ruler; background-color: $color-ruler;
height: 1px; height: 1px;
border: none; border: none;
} }
@@ -146,7 +158,7 @@ h3,
} }
.headline { .headline {
@include mixins.sectionColoursFromParent(); @include sectionColoursFromParent();
&--icon { &--icon {
display: flex; display: flex;
@@ -180,9 +192,9 @@ h1,
display: block; display: block;
font-size: 16px; font-size: 16px;
line-height: 120%; line-height: 120%;
color: colors.$color-brand-primary; color: $color-brand-primary;
text-transform: none; text-transform: none;
@include mixins.sectionColoursFromParent(); @include sectionColoursFromParent();
&--box { &--box {
color: white; color: white;
@@ -217,7 +229,7 @@ h3 {
&--default { &--default {
display: inline-block; display: inline-block;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
padding: 4px 16px; padding: 4px 16px;
} }
@@ -225,7 +237,7 @@ h3 {
&--page-header { &--page-header {
display: block; display: block;
min-width: 186px; min-width: 186px;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
font-size: 18px; font-size: 18px;
padding: 0 32px 0 24px; padding: 0 32px 0 24px;
@@ -238,8 +250,8 @@ h3 {
display: block; display: block;
min-width: 220px; min-width: 220px;
text-align: center; text-align: center;
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
@include mixins.sectionBackgroundsFromParent(); @include sectionBackgroundsFromParent();
color: white; color: white;
line-height: 48px; line-height: 48px;
border: none; border: none;
@@ -259,7 +271,7 @@ h3 {
&:hover { &:hover {
background: white; background: white;
color: colors.$color-font-main; color: $color-font-main;
} }
} }
@@ -268,7 +280,7 @@ h3 {
} }
&--icon { &--icon {
background-color: colors.$color-brand-primary; background-color: $color-brand-primary;
color: white; color: white;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -13,17 +13,9 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
class ApiClient class ApiClient
{ {
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
public const CALENDAR_MODE_DAYS = 'days'; public const CALENDAR_MODE_DAYS = 'days';
public const CALENDAR_MODE_RANGES = 'ranges'; public const CALENDAR_MODE_RANGES = 'ranges';
private ?array $configuration = null;
public function getCalendar( public function getCalendar(
$hotelCode, $hotelCode,
\DateTimeInterface $dateFrom, \DateTimeInterface $dateFrom,
@@ -92,7 +84,7 @@ class ApiClient
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
$config = $this->getConfiguration(); $config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
$baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? '')); $baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? ''));
$apiKey = trim((string)($config['bpnConnectApiKey'] ?? '')); $apiKey = trim((string)($config['bpnConnectApiKey'] ?? ''));
@@ -103,38 +95,10 @@ class ApiClient
throw new ApiException('Bpn Connect API key is not configured.'); throw new ApiException('Bpn Connect API key is not configured.');
} }
$options = [ return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', [
'headers' => [ 'headers' => [
'X-API-KEY' => $apiKey, 'X-API-KEY' => $apiKey,
], ],
]; ]);
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request. The defaults live in
// code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the
// stored configuration verbatim and only syncs the template when an extension has no
// configuration at all, so a newly added template key is absent until someone saves
// extension configuration by hand. Falling back to 0 there would silently mean "no
// timeout" - the opposite of what this guard is for. An explicit 0 still disables.
$timeout = (int)($config['bpnConnectApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['bpnConnectApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
} }
} }
@@ -28,10 +28,10 @@ namespace EP\EpProducts\Command;
***************************************************************/ ***************************************************************/
use EP\EpProducts\Service\DateImportService; use EP\EpProducts\Service\DateImportService;
use EP\EpProducts\Service\ProductImportSourceFactory;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Service\CacheService; use TYPO3\CMS\Extbase\Service\CacheService;
@@ -53,29 +53,21 @@ class DateCommandController extends Command
*/ */
protected $configurationManager; protected $configurationManager;
/**
* @var ProductImportSourceFactory
*/
protected $importSourceFactory;
/** /**
* @param DateImportService $importService * @param DateImportService $importService
* @param CacheService $cacheService * @param CacheService $cacheService
* @param ConfigurationManagerInterface $configurationManager * @param ConfigurationManagerInterface $configurationManager
* @param ProductImportSourceFactory $importSourceFactory
*/ */
public function __construct public function __construct
( (
DateImportService $importService, DateImportService $importService,
CacheService $cacheService, CacheService $cacheService,
ConfigurationManagerInterface $configurationManager, ConfigurationManagerInterface $configurationManager
ProductImportSourceFactory $importSourceFactory
) )
{ {
$this->dateImportService = $importService; $this->dateImportService = $importService;
$this->cacheService = $cacheService; $this->cacheService = $cacheService;
$this->configurationManager = $configurationManager; $this->configurationManager = $configurationManager;
$this->importSourceFactory = $importSourceFactory;
parent::__construct(); parent::__construct();
} }
@@ -87,15 +79,8 @@ class DateCommandController extends Command
*/ */
public function execute(InputInterface $input, OutputInterface $output): int public function execute(InputInterface $input, OutputInterface $output): int
{ {
$importSource = $this->importSourceFactory->get(); $path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport');
$import = $importSource->acquire(); $count = $this->dateImportService->import($path);
if ($import === null) {
$output->writeln('No new import available.');
return 0;
}
$count = $this->dateImportService->import($import->getRows());
$importSource->markImported($import);
$output->writeln(sprintf('%d rows imported.', $count )); $output->writeln(sprintf('%d rows imported.', $count ));
$settings = $this->getSettings(); $settings = $this->getSettings();
$resellerPageUid = $settings['resellerExportPageUid']; $resellerPageUid = $settings['resellerExportPageUid'];
@@ -1,165 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's backchannel endpoints under /api/content/.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md.
*/
class ContentFeedApi
{
public const PATH_PREFIX = '/api/content/';
private const MANIFEST_CACHE_IDENTIFIER = 'content_feed_manifest';
// The consumer polls every 15 minutes, so this caps the build at once per poll however
// often it retries. Edits reach the chatbot within about 25 minutes.
private const MANIFEST_CACHE_LIFETIME = 600;
private const MAX_RECORDS_PER_REQUEST = 100;
private const DISABLED_RETRY_AFTER = 3600;
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
/**
* @var ManifestBuilder
*/
private $manifestBuilder;
/**
* @var RecordSerializer
*/
private $recordSerializer;
/**
* @var FrontendInterface
*/
private $cache;
public function __construct(
ApiKeyAuthenticator $authenticator,
ContentFeedConfiguration $configuration,
ManifestBuilder $manifestBuilder,
RecordSerializer $recordSerializer,
CacheManager $cacheManager
) {
$this->authenticator = $authenticator;
$this->configuration = $configuration;
$this->manifestBuilder = $manifestBuilder;
$this->recordSerializer = $recordSerializer;
$this->cache = $cacheManager->getCache('ep_products_cache');
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
if (!$this->authenticator->isAuthorized($request)) {
return $this->error('Invalid or missing api key', 403);
}
if (!$this->configuration->isEnabled()) {
return self::disabledResponse();
}
if ([] === $this->configuration->getSites()) {
// An empty feed would read as "everything was deleted". Refuse instead.
return $this->error('Content feed has no sites configured', 503);
}
$path = substr($request->getUri()->getPath(), strlen(self::PATH_PREFIX));
if ('manifest' === $path) {
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
}
if ('records' === $path) {
return $this->handleRecordsRequest($request);
}
return $this->error('Not found', 404);
}
/**
* Uids that are not (or no longer) eligible are left out rather than answered with an error:
* the manifest may have changed between the consumer's two calls, and it reads a requested
* but absent uid as deleted.
*/
private function handleRecordsRequest(ServerRequestInterface $request): ResponseInterface
{
$type = (string) ($request->getQueryParams()['type'] ?? '');
if (!isset(RecordFinder::TYPES[$type])) {
return $this->error('Unknown type, expected one of: ' . implode(', ', array_keys(RecordFinder::TYPES)), 400);
}
$uids = array_values(array_unique(array_filter(
GeneralUtility::intExplode(',', (string) ($request->getQueryParams()['uids'] ?? ''), true),
function (int $uid): bool {
return $uid > 0;
}
)));
if ([] === $uids) {
return $this->error('No uids given', 400);
}
if (count($uids) > self::MAX_RECORDS_PER_REQUEST) {
return $this->error(sprintf('At most %d uids per request', self::MAX_RECORDS_PER_REQUEST), 400);
}
return new JsonResponse([
'type' => $type,
'items' => $this->recordSerializer->serialize($type, $uids),
], 200, [], self::JSON_FLAGS);
}
private function getManifest(): array
{
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
if (!is_array($manifest)) {
$manifest = $this->manifestBuilder->build();
$this->cache->set(self::MANIFEST_CACHE_IDENTIFIER, $manifest, [], self::MANIFEST_CACHE_LIFETIME);
}
return $manifest;
}
/**
* The kill switch: contentFeedEnabled = 0 in the extension configuration, for when the host
* is struggling. The consumer stops its run, keeps everything it has and tries again later -
* nothing is deleted on the chatbot side.
*/
public static function disabledResponse(): ResponseInterface
{
return new JsonResponse(
['message' => 'Content feed is switched off'],
503,
['Retry-After' => (string) self::DISABLED_RETRY_AFTER],
self::JSON_FLAGS
);
}
private function error(string $message, int $status): ResponseInterface
{
return new JsonResponse(['message' => $message], $status, [], self::JSON_FLAGS);
}
}
@@ -1,75 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's extension configuration.
*
* Missing keys fall back to "feed is on, but covers nothing": a key added to ext_conf_template.txt
* is absent until the extension configuration is saved in the backend, and an empty feed is
* refused by the consumer rather than read as "everything was deleted".
*/
class ContentFeedConfiguration
{
/**
* @var array
*/
private $configuration;
public function __construct(ExtensionConfiguration $extensionConfiguration)
{
$this->configuration = (array) $extensionConfiguration->get('ep_products');
}
public function isEnabled(): bool
{
return (bool) ($this->configuration['contentFeedEnabled'] ?? true);
}
/**
* @return string[] site identifiers
*/
public function getSites(): array
{
return GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedSites'] ?? ''), true);
}
/**
* @return int[] language ids; the default language when unset
*/
public function getLanguages(): array
{
$languages = GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedLanguages'] ?? '0'), true);
return [] === $languages ? [0] : $languages;
}
/**
* @return int[] page uids whose whole subtree stays out of the feed
*/
public function getExcludedPages(): array
{
return GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedExcludedPages'] ?? ''), true);
}
/**
* @return array<string, int> site identifier => news detail page uid
*/
public function getNewsDetailPages(): array
{
$pages = [];
foreach (GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedNewsDetailPages'] ?? ''), true) as $entry) {
$parts = GeneralUtility::trimExplode(':', $entry, true);
if (2 === count($parts) && (int) $parts[1] > 0) {
$pages[$parts[0]] = (int) $parts[1];
}
}
return $pages;
}
}
@@ -1,379 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* Builds GET /api/content/manifest: everything the content feed publishes, with a modification
* marker per item and no bodies. The list is complete - an item missing from it is gone - so the
* consumer never has to infer deletions from timestamps.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §4.
*/
class ManifestBuilder
{
public const CONTENT_PAGE_TYPE = 1730;
private const DOKTYPE_DEFAULT = 1;
// Recycler and backend user section: nothing below them is public.
private const DOKTYPES_NOT_TRAVERSED = [6, 255];
// l18n_cfg bit 1: "hide default translation of page".
private const L18N_HIDE_DEFAULT_LANGUAGE = 1;
private const RECORD_FIELDS = [
'product' => ['uid', 'tstamp', 'detail_page'],
'hotel' => ['uid', 'tstamp', 'detail_page'],
'region' => ['uid', 'tstamp', 'detail_page'],
'country' => ['uid', 'tstamp', 'detail_page'],
'city' => ['uid', 'tstamp', 'detail_page'],
'concept' => ['uid', 'tstamp', 'detail_page'],
'news' => ['uid', 'tstamp', 'pid'],
];
/**
* @var PageTree
*/
private $pageTree;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
PageTree $pageTree,
UrlResolver $urlResolver,
RecordFinder $recordFinder,
ContentFeedConfiguration $configuration,
ConnectionPool $connectionPool
) {
$this->pageTree = $pageTree;
$this->urlResolver = $urlResolver;
$this->recordFinder = $recordFinder;
$this->configuration = $configuration;
$this->connectionPool = $connectionPool;
}
public function build(): array
{
$sites = $this->urlResolver->getIncludedSites();
$records = $this->buildRecordItems();
return [
'generatedAt' => date('c'),
'sites' => array_map([$this, 'describeSite'], $sites),
'items' => array_merge($this->buildPageItems($sites, $this->collectDetailPages()), $records),
];
}
private function describeSite(Site $site): array
{
$languages = [];
foreach ($site->getLanguages() as $language) {
$languages[] = $language->getTwoLetterIsoCode();
}
$title = trim((string) ($site->getConfiguration()['websiteTitle'] ?? ''));
if ('' === $title) {
$title = trim($site->getDefaultLanguage()->getWebsiteTitle());
}
return [
'identifier' => $site->getIdentifier(),
'title' => '' !== $title ? $title : $site->getIdentifier(),
'base' => (string) $site->getBase(),
'languages' => $languages,
];
}
/**
* @param Site[] $sites
* @param array<int, array> $detailPages detailOf entries by page uid
*/
private function buildPageItems(array $sites, array $detailPages): array
{
$content = $this->findContentStatistics();
$excluded = array_flip($this->configuration->getExcludedPages());
$feedLanguages = $this->configuration->getLanguages();
$items = [];
foreach ($sites as $site) {
$languageIds = [];
foreach ($site->getLanguages() as $language) {
if (in_array($language->getLanguageId(), $feedLanguages, true)) {
$languageIds[$language->getLanguageId()] = $language->getTwoLetterIsoCode();
}
}
$context = [
'site' => $site,
'languages' => $languageIds,
'excluded' => $excluded,
'content' => $content,
'detailPages' => $detailPages,
];
$breadcrumbs = array_fill_keys(array_keys($languageIds), []);
$this->walk($site->getRootPageId(), $context, $breadcrumbs, true, $items);
}
return $items;
}
/**
* @param array<int, string[]> $breadcrumbs titles above this page, by language id
*/
private function walk(int $uid, array $context, array $breadcrumbs, bool $isRoot, array &$items): void
{
$page = $this->pageTree->getPage($uid);
if (null === $page || isset($context['excluded'][$uid])) {
return;
}
if (in_array((int) $page['doktype'], self::DOKTYPES_NOT_TRAVERSED, true)) {
return;
}
$visible = $this->pageTree->isVisible($page);
if (!$visible && (int) $page['extendToSubpages'] === 1) {
return;
}
$childBreadcrumbs = $breadcrumbs;
foreach ($context['languages'] as $languageId => $isoCode) {
$row = $this->getLanguageRow($page, $languageId);
if ($visible && null !== $row && $this->isPublishable($page, $row, $languageId)) {
$item = $this->buildPageItem($page, $row, $languageId, $isoCode, $breadcrumbs[$languageId], $context);
if (null !== $item) {
$items[] = $item;
}
}
if (!$isRoot) {
$source = $row ?? $page;
$childBreadcrumbs[$languageId][] = '' !== trim((string) $source['nav_title'])
? trim((string) $source['nav_title'])
: trim((string) $source['title']);
}
}
foreach ($this->pageTree->getChildren($uid) as $childUid) {
$this->walk($childUid, $context, $childBreadcrumbs, false, $items);
}
}
private function getLanguageRow(array $page, int $languageId): ?array
{
if (0 === $languageId) {
return $page;
}
$overlay = $this->pageTree->getOverlay((int) $page['uid'], $languageId);
return null !== $overlay && $this->pageTree->isVisible($overlay) ? $overlay : null;
}
/**
* Mirrors the XML sitemap: standard pages only, nothing marked no_index. If Google is not
* meant to see a page, the chatbot is not either.
*/
private function isPublishable(array $page, array $row, int $languageId): bool
{
if ((int) $page['doktype'] !== self::DOKTYPE_DEFAULT || (int) $row['no_index'] === 1) {
return false;
}
return 0 !== $languageId || 0 === ((int) $page['l18n_cfg'] & self::L18N_HIDE_DEFAULT_LANGUAGE);
}
private function buildPageItem(array $page, array $row, int $languageId, string $isoCode, array $breadcrumb, array $context): ?array
{
$uid = (int) $page['uid'];
// "Show content from page": the elements live on another page, and so do their changes.
// The site answers 404 when that page is hidden.
$contentPid = (int) $page['content_from_pid'] ?: $uid;
$contentPage = $this->pageTree->getPage($contentPid);
if (null === $contentPage || !$this->pageTree->isVisible($contentPage)) {
return null;
}
// Without a visible element other than plugins, the content-only render is empty (it
// leaves plugins out), so there is nothing to fetch. That is most product and hotel
// detail pages: their text comes through the records endpoint.
$content = $context['content'][$contentPid] ?? [];
$textElements = ($content[$languageId]['text'] ?? 0) + ($content[-1]['text'] ?? 0);
if (0 === $textElements) {
return null;
}
$url = $this->urlResolver->getPageUrl($uid, $languageId);
if (null === $url) {
return null;
}
$modified = max(
(int) $row['tstamp'],
$content[$languageId]['tstamp'] ?? 0,
$content[-1]['tstamp'] ?? 0
);
return [
'type' => 'page',
'uid' => $uid,
'language' => $isoCode,
'site' => $context['site']->getIdentifier(),
'url' => $url,
'contentUrl' => $this->urlResolver->getPageUrl($uid, $languageId, ['type' => self::CONTENT_PAGE_TYPE]),
'title' => trim((string) $row['title']),
'description' => $this->nullIfEmpty((string) $row['description']),
'breadcrumb' => $breadcrumb,
'detailOf' => $context['detailPages'][$uid] ?? [],
'modified' => date('c', $modified),
];
}
/**
* Per page and language: the latest change to any content element, and how many visible
* elements are not plugins.
* Deleted and hidden elements count towards the change - removing or hiding one changes the
* page - which is also why SYS_LASTCHANGED is no use here: it only moves on render.
*
* @return array<int, array<int, array{tstamp: int, text: int}>>
*/
private function findContentStatistics(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral(
'pid',
'sys_language_uid',
'MAX(tstamp) AS tstamp',
"SUM(CASE WHEN deleted = 0 AND hidden = 0 AND CType <> 'list' THEN 1 ELSE 0 END) AS text_elements"
)
->from('tt_content')
->groupBy('pid', 'sys_language_uid')
->execute()
->fetchAllAssociative()
;
$statistics = [];
foreach ($rows as $row) {
$statistics[(int) $row['pid']][(int) $row['sys_language_uid']] = [
'tstamp' => (int) $row['tstamp'],
'text' => (int) $row['text_elements'],
];
}
return $statistics;
}
/**
* Which records name a page as their detail page. Keyed by the page the record points at,
* not by where a shortcut leads: a shortcut is not in the manifest, and the page it leads to
* usually describes something broader (product detail shortcuts point at region pages).
*
* @return array<int, array>
*/
private function collectDetailPages(): array
{
$detailPages = [];
foreach (self::RECORD_FIELDS as $type => $fields) {
if (!in_array('detail_page', $fields, true)) {
continue;
}
foreach ($this->recordFinder->find($type, ['uid', 'detail_page']) as $uid => $row) {
$pageUid = $this->urlResolver->parsePageReference((string) $row['detail_page']);
if (null !== $pageUid) {
$detailPages[$pageUid][] = ['type' => $type, 'uid' => $uid];
}
}
}
return $detailPages;
}
private function buildRecordItems(): array
{
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$items = [];
foreach (self::RECORD_FIELDS as $type => $fields) {
foreach ($this->recordFinder->find($type, $fields) as $uid => $row) {
if ('news' === $type) {
$link = $this->urlResolver->getNewsLink((int) $row['uid'], (int) $row['pid']);
// News has no detail_page of its own: an article stored outside the feed's
// sites (the retired ep-events) is not part of the feed at all.
if (null === $link) {
continue;
}
} else {
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
}
$modified = (int) $row['tstamp'];
if ('product' === $type) {
$modified = max($modified, $faqTimestamps[$uid] ?? 0);
}
if ('hotel' === $type) {
$modified = max($modified, $matchcodeTimestamps[$uid] ?? 0);
}
$items[] = [
'type' => $type,
'uid' => $uid,
'language' => 'de',
'site' => $link['site'],
'url' => $link['url'],
'modified' => date('c', $modified),
];
}
}
return $items;
}
private function nullIfEmpty(string $value): ?string
{
$value = trim($value);
return '' === $value ? null : $value;
}
}
@@ -1,131 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
/**
* The whole page table in memory, for the content feed's tree walk and shortcut resolution.
*
* About 2 900 rows: one query and a PHP walk is far cheaper than a query per tree level.
* Visibility is evaluated here, not in SQL, because a hidden page still has to be traversed
* (its subpages stay reachable unless extendToSubpages is set).
*/
class PageTree
{
private const FIELDS = [
'uid', 'pid', 'sorting', 'doktype', 'hidden', 'starttime', 'endtime', 'fe_group',
'extendToSubpages', 'no_index', 'title', 'nav_title', 'description', 'shortcut',
'shortcut_mode', 'content_from_pid', 'tstamp', 'sys_language_uid', 'l10n_parent', 'l18n_cfg',
];
/**
* @var ConnectionPool
*/
private $connectionPool;
/**
* @var int
*/
private $now;
/**
* @var array<int, array>|null default-language rows by uid
*/
private $pages;
/**
* @var array<int, int[]> child uids by parent uid, in sorting order
*/
private $children = [];
/**
* @var array<int, array<int, array>> overlay rows by default-language uid and language id
*/
private $overlays = [];
public function __construct(ConnectionPool $connectionPool, Context $context)
{
$this->connectionPool = $connectionPool;
$this->now = (int) $context->getPropertyFromAspect('date', 'timestamp');
}
public function getPage(int $uid): ?array
{
$this->load();
return $this->pages[$uid] ?? null;
}
public function getOverlay(int $uid, int $languageId): ?array
{
$this->load();
return $this->overlays[$uid][$languageId] ?? null;
}
/**
* @return int[]
*/
public function getChildren(int $uid): array
{
$this->load();
return $this->children[$uid] ?? [];
}
/**
* Visible to an anonymous frontend visitor: not hidden, inside its start/end window, and not
* restricted to a frontend user group.
*/
public function isVisible(array $row): bool
{
if ((int) $row['hidden'] === 1) {
return false;
}
if ((int) $row['starttime'] > $this->now) {
return false;
}
if ((int) $row['endtime'] !== 0 && (int) $row['endtime'] <= $this->now) {
return false;
}
return in_array((string) $row['fe_group'], ['', '0'], true);
}
private function load(): void
{
if (null !== $this->pages) {
return;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()->removeAll()->add(new DeletedRestriction());
$rows = $queryBuilder
->select(...self::FIELDS)
->from('pages')
->orderBy('sorting')
->execute()
->fetchAllAssociative()
;
$this->pages = [];
foreach ($rows as $row) {
$uid = (int) $row['uid'];
if ((int) $row['sys_language_uid'] > 0) {
$this->overlays[(int) $row['l10n_parent']][(int) $row['sys_language_uid']] = $row;
continue;
}
$this->pages[$uid] = $row;
$this->children[(int) $row['pid']][] = $uid;
}
}
}
@@ -1,152 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Which records the content feed publishes. The manifest and the records endpoint both go
* through here, so a uid the manifest lists is exactly a uid the records endpoint returns.
*
* The default restrictions apply: deleted, hidden, starttime and endtime.
*/
class RecordFinder
{
public const TYPES = [
'product' => 'tx_epproducts_domain_model_product',
'hotel' => 'tx_epproducts_domain_model_hotel',
'region' => 'tx_epproducts_domain_model_region',
'country' => 'tx_epproducts_domain_model_country',
'city' => 'tx_epproducts_domain_model_city',
'concept' => 'tx_epproducts_domain_model_concept',
'news' => 'tx_news_domain_model_news',
];
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(ConnectionPool $connectionPool)
{
$this->connectionPool = $connectionPool;
}
/**
* @param string[] $fields
* @param int[]|null $uids null for all eligible records
* @return array<int, array> rows by uid, in uid order
*/
public function find(string $type, array $fields, ?array $uids = null): array
{
$table = self::TYPES[$type];
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$queryBuilder
->select(...$fields)
->from($table)
->where($queryBuilder->expr()->in('sys_language_uid', [0, -1]))
->orderBy('uid')
;
if (null !== $uids) {
if ([] === $uids) {
return [];
}
$queryBuilder->andWhere($queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)
));
}
$this->addTypeConstraints($type, $queryBuilder);
$rows = [];
foreach ($queryBuilder->execute()->fetchAllAssociative() as $row) {
$rows[(int) $row['uid']] = $row;
}
return $rows;
}
/**
* Latest change to a product's FAQs, by product uid. Deleted and hidden FAQs count: removing
* one is a change to the product's text.
*
* @return array<int, int>
*/
public function findFaqTimestamps(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral('mm.uid_local AS product', 'MAX(faq.tstamp) AS tstamp')
->from('tx_epproducts_product_faq_mm', 'mm')
->join('mm', 'tx_epproducts_domain_model_faq', 'faq', 'faq.uid = mm.uid_foreign')
->groupBy('mm.uid_local')
->execute()
->fetchAllAssociative()
;
return $this->indexTimestamps($rows, 'product');
}
/**
* Latest change to a hotel's matchcode aliases, by hotel uid. They are part of the hotel's
* `codes`, which the consumer joins its trip data on.
*
* @return array<int, int>
*/
public function findMatchcodeTimestamps(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral('entity AS hotel', 'MAX(tstamp) AS tstamp')
->from('tx_epproducts_domain_model_matchcode')
->where($queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')))
->groupBy('entity')
->execute()
->fetchAllAssociative()
;
return $this->indexTimestamps($rows, 'hotel');
}
/**
* @return array<int, int>
*/
private function indexTimestamps(array $rows, string $key): array
{
$timestamps = [];
foreach ($rows as $row) {
$timestamps[(int) $row[$key]] = (int) $row['tstamp'];
}
return $timestamps;
}
private function addTypeConstraints(string $type, QueryBuilder $queryBuilder): void
{
$expr = $queryBuilder->expr();
if ('product' === $type) {
// A product that is neither searchable nor bookable is an internal record.
$queryBuilder->andWhere($expr->orX($expr->eq('searchable', 1), $expr->eq('bookable', 1)));
}
if ('news' === $type) {
// Type 0 is a regular article; 1 and 2 are just links to a page or an external URL.
$queryBuilder->andWhere(
$expr->eq('type', $queryBuilder->createNamedParameter('0')),
$expr->in('fe_group', $queryBuilder->createNamedParameter(['', '0'], Connection::PARAM_STR_ARRAY))
);
}
}
}
@@ -1,513 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Builds GET /api/content/records: the editorial content of products, hotels, destinations,
* concepts and blog articles. Filter attributes and text only - no dates, prices or images.
* Dates and prices are the consumer's own data; images play no part in a text chatbot.
*
* Rich-text fields go out as stored HTML with `t3://` links resolved; the consumer converts
* them to Markdown together with the rendered pages, so there is one HTML-to-text step.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §5.
*/
class RecordSerializer
{
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RteLinkResolver
*/
private $rteLinkResolver;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
RecordFinder $recordFinder,
UrlResolver $urlResolver,
RteLinkResolver $rteLinkResolver,
ConnectionPool $connectionPool
) {
$this->recordFinder = $recordFinder;
$this->urlResolver = $urlResolver;
$this->rteLinkResolver = $rteLinkResolver;
$this->connectionPool = $connectionPool;
}
/**
* @param int[] $uids
* @return array[] one item per eligible uid; ineligible uids are left out
*/
public function serialize(string $type, array $uids): array
{
$rows = $this->recordFinder->find($type, ['*'], $uids);
if ([] === $rows) {
return [];
}
switch ($type) {
case 'product':
return $this->serializeProducts($rows);
case 'hotel':
return $this->serializeHotels($rows);
case 'news':
return $this->serializeNews($rows);
default:
return array_values(array_map(function (array $row) use ($type): array {
return $this->serializeDestination($type, $row);
}, $rows));
}
}
private function serializeProducts(array $rows): array
{
$uids = array_keys($rows);
$hotels = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_local', 'uid_foreign', $uids);
$regions = $this->findMmUids('tx_epproducts_product_region_mm', 'uid_local', 'uid_foreign', $uids);
$faqs = $this->findFaqs($uids);
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$infoboxes = $this->findInfoboxTexts(array_column($rows, 'non_bookable_info'));
$items = [];
foreach ($rows as $uid => $row) {
$season = trim((string) $row['season']);
$items[] = $this->withLink($row, max((int) $row['tstamp'], $faqTimestamps[$uid] ?? 0)) + [
'code' => $this->text($row['code']),
'busProId' => (int) $row['bus_pro_id'] ?: null,
'name' => $this->text($row['name']),
'subline' => $this->text($row['subline']),
'headline' => $this->text($row['headline']),
'season' => '' === $season ? 'w' : $season,
'daytrip' => (bool) $row['daytrip'],
'bookable' => (bool) $row['bookable'],
'searchable' => (bool) $row['searchable'],
'skipassIncluded' => (bool) $row['skipass_included'],
'conceptUid' => (int) $row['concept'] ?: null,
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'additionalRegionUids' => $regions[$uid] ?? [],
'hotelUids' => $hotels[$uid] ?? [],
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'teaserLong' => $this->html($row['teaser_long']),
'feature' => $this->text($row['feature']),
'conceptDescription' => $this->html($row['concept_description']),
'boardDescription' => $this->html($row['board_description']),
'includedServices' => $this->html($row['included_services']),
'additionalServices' => $this->html($row['additional_services']),
'programmeDescription' => $this->html($row['programme_description']),
'skiPassDescription' => $this->html($row['ski_pass_description']),
'nonBookableInfo' => $infoboxes[(int) $row['non_bookable_info']] ?? null,
],
'faqs' => $faqs[$uid] ?? [],
];
}
return $items;
}
private function serializeHotels(array $rows): array
{
$uids = array_keys($rows);
$aliases = $this->findMatchcodes($uids);
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$products = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_foreign', 'uid_local', $uids, 'sorting_foreign');
$eligibleProducts = $this->recordFinder->find('product', ['uid'], array_values(array_unique(array_merge([], ...array_values($products)))));
$items = [];
foreach ($rows as $uid => $row) {
$code = trim((string) $row['code']);
$codes = array_values(array_unique(array_filter(array_merge([$code], $aliases[$uid] ?? []), 'strlen')));
$items[] = $this->withLink($row, max((int) $row['tstamp'], $matchcodeTimestamps[$uid] ?? 0)) + [
'code' => '' === $code ? null : $code,
'codes' => $codes,
'name' => $this->text($row['name']),
'shortName' => $this->text($row['short_name']),
'headline' => $this->text($row['headline']),
'type' => (int) $row['type'],
'category' => (int) $row['category'],
'new' => (bool) $row['new'],
'earlybird' => (bool) $row['earlybird'],
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'productUids' => array_values(array_filter($products[$uid] ?? [], function (int $productUid) use ($eligibleProducts): bool {
return isset($eligibleProducts[$productUid]);
})),
'address' => $this->text($row['address']),
'latitude' => $this->text($row['latitude']),
'longitude' => $this->text($row['longitude']),
'facts' => [
'bedsCount' => (int) $row['beds_count'] ?: null,
'parkingCount' => (int) $row['parking_count'] ?: null,
'altitude' => $this->text($row['altitude']),
'distanceBus' => $this->text($row['distance_bus']),
'distanceLift' => $this->text($row['distance_lift']),
'shower' => $this->text($row['shower']),
'partyroom' => (bool) $row['partyroom'],
'gamesroom' => (bool) $row['gamesroom'],
'sauna' => (bool) $row['sauna'],
'wifi' => (bool) $row['wifi'],
],
'icons' => HotelIconUtility::getIcons($row),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'features' => $this->html($row['features']),
'roomTypes' => $this->html($row['room_types']),
'additionalInformation' => $this->html($row['additional_information']),
],
];
}
return $items;
}
private function serializeDestination(string $type, array $row): array
{
$item = $this->withLink($row, (int) $row['tstamp']) + [
'name' => $this->text($row['name']),
'headline' => $this->text($row['headline']),
];
switch ($type) {
case 'region':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'facts' => [
'length' => $this->text($row['length']),
'height' => $this->text($row['height']),
'lifts' => $this->text($row['lifts']),
],
'webcam' => $this->findWebcamUrl((int) $row['webcam']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'feature' => $this->text($row['feature']),
'skiArea' => $this->html($row['ski_area']),
'skiAreaExtended' => $this->html($row['ski_area_extended']),
'skiPass' => $this->html($row['ski_pass']),
'news' => $this->html($row['news']),
],
];
case 'country':
return $item + [
'code' => $this->text($row['code']),
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
],
];
case 'city':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'party' => $this->html($row['party']),
'leisure' => $this->html($row['leisure']),
],
];
default: // concept
return $item + [
'code' => $this->text($row['code']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'popupText' => $this->html($row['popup_text']),
],
];
}
}
private function serializeNews(array $rows): array
{
$uids = array_keys($rows);
$tags = $this->findNewsTags($uids);
$categories = $this->findNewsCategories($uids);
$items = [];
foreach ($rows as $uid => $row) {
$link = $this->urlResolver->getNewsLink($uid, (int) $row['pid']);
if (null === $link) {
continue;
}
$items[] = [
'uid' => $uid,
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', (int) $row['tstamp']),
'title' => $this->text($row['title']),
'teaser' => $this->html($row['teaser']),
'bodytext' => $this->html($row['bodytext']),
'datetime' => (int) $row['datetime'] > 0 ? date('c', (int) $row['datetime']) : null,
'tags' => $tags[$uid] ?? [],
'categories' => $categories[$uid] ?? [],
];
}
return $items;
}
/**
* The identity and link fields every record carries, with the same values as the manifest.
*/
private function withLink(array $row, int $modified): array
{
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
return [
'uid' => (int) $row['uid'],
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', $modified),
];
}
/**
* @param int[] $uids
* @return array<int, int[]> related uids by $keyField, in $sortField order
*/
private function findMmUids(string $table, string $keyField, string $valueField, array $uids, string $sortField = 'sorting'): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$rows = $queryBuilder
->select($keyField, $valueField)
->from($table)
->where($queryBuilder->expr()->in($keyField, $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)))
->orderBy($keyField)
->addOrderBy($sortField)
->execute()
->fetchAllAssociative()
;
$related = [];
foreach ($rows as $row) {
$related[(int) $row[$keyField]][] = (int) $row[$valueField];
}
return $related;
}
/**
* @param int[] $productUids
* @return array<int, array[]> FAQs by product uid, in the order the editor arranged them
*/
private function findFaqs(array $productUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$rows = $queryBuilder
->select('mm.uid_local AS product', 'faq.question', 'faq.answer', 'faq.category')
->from('tx_epproducts_domain_model_faq', 'faq')
->join('faq', 'tx_epproducts_product_faq_mm', 'mm', 'mm.uid_foreign = faq.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.uid_local')
->addOrderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$faqs = [];
foreach ($rows as $row) {
$faqs[(int) $row['product']][] = [
'question' => $this->text($row['question']),
'answer' => $this->html($row['answer']),
'category' => $this->text($row['category']),
];
}
return $faqs;
}
/**
* Matchcode aliases by hotel uid. With the hotel's own code they are every code BusPro may use
* for it - the same set DateImportService::getHotelMappings() resolves.
*
* @param int[] $hotelUids
* @return array<int, string[]>
*/
private function findMatchcodes(array $hotelUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$rows = $queryBuilder
->select('entity', 'code')
->from('tx_epproducts_domain_model_matchcode')
->where(
$queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')),
$queryBuilder->expr()->in('entity', $queryBuilder->createNamedParameter($hotelUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('code')
->execute()
->fetchAllAssociative()
;
$codes = [];
foreach ($rows as $row) {
$codes[(int) $row['entity']][] = trim((string) $row['code']);
}
return $codes;
}
/**
* @param array $infoboxUids
* @return array<int, string|null>
*/
private function findInfoboxTexts(array $infoboxUids): array
{
$infoboxUids = array_values(array_filter(array_map('intval', $infoboxUids)));
if ([] === $infoboxUids) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_infobox');
$rows = $queryBuilder
->select('uid', 'text')
->from('tx_epproducts_domain_model_infobox')
->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter(array_unique($infoboxUids), Connection::PARAM_INT_ARRAY)))
->execute()
->fetchAllAssociative()
;
$texts = [];
foreach ($rows as $row) {
$texts[(int) $row['uid']] = $this->html($row['text']);
}
return $texts;
}
private function findWebcamUrl(int $webcamUid): ?string
{
if (0 === $webcamUid) {
return null;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_webcam');
$url = $queryBuilder
->select('url')
->from('tx_epproducts_domain_model_webcam')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($webcamUid, \PDO::PARAM_INT)))
->execute()
->fetchOne()
;
return false === $url ? null : $this->text($url);
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsTags(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_tag');
$rows = $queryBuilder
->select('mm.uid_local AS news', 'tag.title')
->from('tx_news_domain_model_tag', 'tag')
->join('tag', 'tx_news_domain_model_news_tag_mm', 'mm', 'mm.uid_foreign = tag.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$tags = [];
foreach ($rows as $row) {
$tags[(int) $row['news']][] = trim((string) $row['title']);
}
return $tags;
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsCategories(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('mm.uid_foreign AS news', 'category.title')
->from('sys_category', 'category')
->join('category', 'sys_category_record_mm', 'mm', 'mm.uid_local = category.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news')),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories')),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('mm.sorting_foreign')
->execute()
->fetchAllAssociative()
;
$categories = [];
foreach ($rows as $row) {
$categories[(int) $row['news']][] = trim((string) $row['title']);
}
return $categories;
}
/**
* Rich text as stored, with internal links made absolute.
*/
private function html($value): ?string
{
$value = trim((string) $value);
return '' === $value ? null : $this->rteLinkResolver->resolve($value);
}
/**
* Plain text with whitespace collapsed. Product names carry literal line breaks the website
* splits on for layout ("Apartments \nVal Thorens"); the chatbot needs them as one line.
*/
private function text($value): ?string
{
$value = trim((string) preg_replace('/\s+/u', ' ', (string) $value));
return '' === $value ? null : $value;
}
}
@@ -1,104 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\File;
/**
* Rewrites `t3://` links in stored RTE HTML to absolute URLs.
*
* parseFunc_RTE would do this, but it needs a TSFE and the API middleware runs before one exists.
* Only links are touched; the rest of the HTML is passed on as stored. A link that cannot be
* resolved to something public (a page outside the feed's sites, a deleted page) loses its href
* and keeps its text.
*/
class RteLinkResolver
{
/**
* @var LinkService
*/
private $linkService;
/**
* @var UrlResolver
*/
private $urlResolver;
public function __construct(LinkService $linkService, UrlResolver $urlResolver)
{
$this->linkService = $linkService;
$this->urlResolver = $urlResolver;
}
public function resolve(string $html): string
{
if (false === strpos($html, 't3://')) {
return $html;
}
return (string) preg_replace_callback(
'/\shref=(["\'])(t3:\/\/[^"\']*)\1/i',
function (array $matches): string {
$url = $this->resolveLink(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5));
return null === $url ? '' : ' href="' . htmlspecialchars($url) . '"';
},
$html
);
}
private function resolveLink(string $link): ?string
{
try {
$data = $this->linkService->resolve($link);
} catch (\Throwable $e) {
return null;
}
if (LinkService::TYPE_PAGE === ($data['type'] ?? null)) {
$pageUid = $this->urlResolver->parsePageReference('t3://page?uid=' . ($data['pageuid'] ?? ''));
$target = null === $pageUid ? null : $this->urlResolver->resolveLinkTarget($pageUid);
$url = null === $target ? null : $this->urlResolver->getPageUrl($target);
if (null !== $url && '' !== (string) ($data['fragment'] ?? '')) {
$url .= '#' . $data['fragment'];
}
return $url;
}
if (LinkService::TYPE_FILE === ($data['type'] ?? null) && ($data['file'] ?? null) instanceof File) {
return $this->absolute((string) $data['file']->getPublicUrl());
}
if (LinkService::TYPE_URL === ($data['type'] ?? null)) {
return (string) $data['url'];
}
return null;
}
/**
* File URLs come back relative to the document root; the consumer has no page to resolve
* them against for record text, so make them absolute against the main site.
*/
private function absolute(string $url): ?string
{
if ('' === $url) {
return null;
}
if (1 === preg_match('/^https?:\/\//', $url)) {
return $url;
}
$sites = $this->urlResolver->getIncludedSites();
if ([] === $sites) {
return null;
}
return rtrim((string) $sites[0]->getBase(), '/') . '/' . ltrim($url, '/');
}
}
@@ -1,214 +0,0 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Absolute public URLs for the content feed, generated through each site's router so that
* baseVariants apply (staging and DDEV get their own hosts) and route enhancers shape the path.
*/
class UrlResolver
{
private const DOKTYPE_DEFAULT = 1;
private const DOKTYPE_SHORTCUT = 4;
private const SHORTCUT_MODE_NONE = 0;
private const SHORTCUT_MODE_FIRST_SUBPAGE = 1;
private const SHORTCUT_MODE_PARENT_PAGE = 3;
private const MAX_SHORTCUT_DEPTH = 5;
/**
* @var SiteFinder
*/
private $siteFinder;
/**
* @var PageTree
*/
private $pageTree;
/**
* @var string[]
*/
private $includedSites;
/**
* @var array<string, int> news detail page uid by site identifier
*/
private $newsDetailPages;
/**
* @var array<int, Site|null>
*/
private $sitesByPage = [];
public function __construct(SiteFinder $siteFinder, PageTree $pageTree, ContentFeedConfiguration $configuration)
{
$this->siteFinder = $siteFinder;
$this->pageTree = $pageTree;
$this->includedSites = $configuration->getSites();
$this->newsDetailPages = $configuration->getNewsDetailPages();
}
/**
* @return Site[] included sites, in configuration order
*/
public function getIncludedSites(): array
{
$sites = [];
foreach ($this->includedSites as $identifier) {
try {
$sites[] = $this->siteFinder->getSiteByIdentifier($identifier);
} catch (SiteNotFoundException $e) {
// A misspelt identifier must not take the whole feed down; it just contributes nothing.
}
}
return $sites;
}
/**
* The page's site, or null when it belongs to no site or to one outside the feed.
*/
public function getIncludedSite(int $pageUid): ?Site
{
if (!array_key_exists($pageUid, $this->sitesByPage)) {
try {
$site = $this->siteFinder->getSiteByPageId($pageUid);
$this->sitesByPage[$pageUid] = in_array($site->getIdentifier(), $this->includedSites, true) ? $site : null;
} catch (SiteNotFoundException $e) {
$this->sitesByPage[$pageUid] = null;
}
}
return $this->sitesByPage[$pageUid];
}
public function getPageUrl(int $pageUid, int $languageId = 0, array $parameters = []): ?string
{
$site = $this->getIncludedSite($pageUid);
if (null === $site) {
return null;
}
try {
$language = $site->getLanguageById($languageId);
return (string) $site->getRouter()->generateUri($pageUid, ['_language' => $language] + $parameters);
} catch (\Throwable $e) {
// Unroutable pages (missing slug, disabled language) are left out rather than failing the feed.
return null;
}
}
/**
* Parses a typolink page reference as stored in `detail_page`: a plain uid or `t3://page?uid=N`.
*/
public function parsePageReference(string $reference): ?int
{
$reference = trim($reference);
if (MathUtility::canBeInterpretedAsInteger($reference)) {
return (int) $reference > 0 ? (int) $reference : null;
}
if (1 === preg_match('/^t3:\/\/page\?(?:.*&)?uid=(\d+)/', html_entity_decode($reference), $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Follows shortcut pages the way a typolink does, and returns the uid of the visible standard
* page a link to $pageUid ends up on - or null when the chain breaks or loops.
*/
public function resolveLinkTarget(int $pageUid): ?int
{
for ($depth = 0; $depth < self::MAX_SHORTCUT_DEPTH; $depth++) {
$page = $this->pageTree->getPage($pageUid);
if (null === $page || !$this->pageTree->isVisible($page)) {
return null;
}
if ((int) $page['doktype'] !== self::DOKTYPE_SHORTCUT) {
return (int) $page['doktype'] === self::DOKTYPE_DEFAULT ? $pageUid : null;
}
$next = $this->getShortcutTarget($page);
if (null === $next) {
return null;
}
$pageUid = $next;
}
return null;
}
/**
* @return array{url: ?string, site: ?string}
*/
public function getRecordLink(string $detailPage): array
{
$pageUid = $this->parsePageReference($detailPage);
$target = null === $pageUid ? null : $this->resolveLinkTarget($pageUid);
$site = null === $target ? null : $this->getIncludedSite($target);
$url = null === $site ? null : $this->getPageUrl($target);
return [
'url' => $url,
'site' => null === $url ? null : $site->getIdentifier(),
];
}
/**
* News has no detail_page of its own: the storage folder decides the site, and the site's
* configured detail page carries the article through the NewsDetailPlugin route enhancer.
*
* @return array{url: ?string, site: string}|null null when the article is stored outside the feed's sites
*/
public function getNewsLink(int $newsUid, int $storagePid): ?array
{
$site = $this->getIncludedSite($storagePid);
if (null === $site) {
return null;
}
$detailPage = $this->newsDetailPages[$site->getIdentifier()] ?? null;
$url = null === $detailPage ? null : $this->getPageUrl($detailPage, 0, [
'tx_news_pi1' => ['action' => 'detail', 'controller' => 'News', 'news' => $newsUid],
]);
return ['url' => $url, 'site' => $site->getIdentifier()];
}
private function getShortcutTarget(array $page): ?int
{
switch ((int) $page['shortcut_mode']) {
case self::SHORTCUT_MODE_NONE:
return (int) $page['shortcut'] > 0 ? (int) $page['shortcut'] : null;
case self::SHORTCUT_MODE_FIRST_SUBPAGE:
foreach ($this->pageTree->getChildren((int) $page['uid']) as $childUid) {
if ($this->pageTree->isVisible($this->pageTree->getPage($childUid))) {
return $childUid;
}
}
return null;
case self::SHORTCUT_MODE_PARENT_PAGE:
return (int) $page['pid'] > 0 ? (int) $page['pid'] : null;
default:
// Random subpage: there is no stable URL to give.
return null;
}
}
}
@@ -34,7 +34,6 @@ use EP\EpProducts\MyEP\ApiException;
use EP\EpProducts\Utility\DateUtility; use EP\EpProducts\Utility\DateUtility;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
class AjaxCalendarController extends ActionController class AjaxCalendarController extends ActionController
{ {
@@ -54,10 +53,7 @@ class AjaxCalendarController extends ActionController
public function priceCalendarAction(Hotel $hotel, int $months = 12): void public function priceCalendarAction(Hotel $hotel, int $months = 12): void
{ {
$hotelCode = trim($hotel->getCode() ?? ''); $hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$dateRange = DateUtility::getDateRange(null, null, $months); $dateRange = DateUtility::getDateRange(null, null, $months);
$period = $dateRange['period'];
$today = new \DateTimeImmutable('today'); $today = new \DateTimeImmutable('today');
$priceDataByDate = []; $priceDataByDate = [];
@@ -66,89 +62,87 @@ class AjaxCalendarController extends ActionController
if ($hotelCode !== '') { if ($hotelCode !== '') {
try { try {
// This action is cacheable, so the rendered markup is held in the page cache $cacheIdentifier = 'myep_price_calendar_' . sha1(implode('|', [
// for config.cache_period. An additional application-level cache here would $hotelCode,
// key on exactly the same granularity (hotel + date range) and only stack a $dateRange['period']->getStartDate()->format('Y-m-d'),
// second TTL on top, pushing worst-case staleness past the freshness budget. $dateRange['period']->getEndDate()->format('Y-m-d'),
$rawData = $this->myEpClient->getPriceConfigData($hotelCode); ]));
$cachedData = $this->cache->get($cacheIdentifier);
$availableDates = []; if (false !== $cachedData) {
foreach ($rawData as $item) { $priceDataByDate = $cachedData;
if (($item['status'] ?? '') === 'OK') { } else {
$availableDates[$item['date']] = true; $rawData = $this->myEpClient->getPriceConfigData(
} $hotelCode,
} $dateRange['period']->getStartDate(),
$dateRange['period']->getEndDate()
);
// The API no longer honours a date-range constraint, so it may return data $availableDates = [];
// further out than the default $months grid covers. Grow the grid to match foreach ($rawData as $item) {
// whatever the API actually returned rather than silently dropping the tail. if (($item['status'] ?? '') === 'OK') {
$lastDataDate = null; $availableDates[$item['date']] = true;
}
foreach ($rawData as $item) {
$date = $item['date'];
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($dt < $today) {
continue;
} }
if (null === $lastDataDate || $dt > $lastDataDate) { foreach ($rawData as $item) {
$lastDataDate = $dt; $date = $item['date'];
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($dt < $today) {
continue;
}
$prevDate = $dt->modify('-1 day')->format('Y-m-d');
$isAvailable = ($item['status'] ?? '') === 'OK';
$prevAvailable = isset($availableDates[$prevDate]);
if ($isAvailable) {
$dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok';
} elseif ($prevAvailable) {
$dayStatus = 'checkout-only';
} else {
$dayStatus = 'blocked';
}
$priceDataByDate[$date] = [
'status' => $dayStatus,
'available' => $isAvailable ? 1 : 0,
'tooltip' => $this->buildTooltip($dayStatus, $item),
'pricePerNight' => $item['pricePerNight'] ?? null,
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
'type' => $item['type'] ?? null,
'currency' => $item['currency'] ?? null,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
} }
$prevDate = $dt->modify('-1 day')->format('Y-m-d'); $this->cache->set($cacheIdentifier, $priceDataByDate, [], self::CACHE_TTL);
$isAvailable = ($item['status'] ?? '') === 'OK';
$prevAvailable = isset($availableDates[$prevDate]);
if ($isAvailable) {
$dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok';
} elseif ($prevAvailable) {
$dayStatus = 'checkout-only';
} else {
$dayStatus = 'blocked';
}
$priceDataByDate[$date] = [
'status' => $dayStatus,
'available' => $isAvailable ? 1 : 0,
'tooltip' => $this->buildTooltip($dayStatus, $item),
'pricePerNight' => $item['pricePerNight'] ?? null,
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
'type' => $item['type'] ?? null,
'currency' => $item['currency'] ?? null,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
}
if (null !== $lastDataDate) {
$dataEnd = $lastDataDate->modify('first day of next month')->setTime(0, 0, 0);
if ($dataEnd > $period->getEndDate()) {
$period = new \League\Period\Period($period->getStartDate(), $dataEnd);
}
} }
} catch (ApiException $e) { } catch (ApiException $e) {
$error = true; $error = true;
$errorMessage = $e->getMessage(); $errorMessage = $e->getMessage();
$this->disablePageCache();
} }
} else { } else {
$error = true; $error = true;
$errorMessage = 'Missing hotel code'; $errorMessage = 'Missing hotel code';
$this->disablePageCache();
} }
$this->view->assignMultiple([ $this->view->assignMultiple([
'priceDataByDate' => $priceDataByDate, 'priceDataByDate' => $priceDataByDate,
'calendarMonths' => $this->buildCalendarMonths($period), 'calendarMonths' => $this->buildCalendarMonths($dateRange['period']),
'error' => $error, 'error' => $error,
'errorMessage' => $errorMessage, 'errorMessage' => $errorMessage,
]); ]);
} }
public function priceConfigAction(Hotel $hotel): void public function priceConfigAction(Hotel $hotel, string $year = null, string $month = null, int $months = 12)
{ {
$dateRange = DateUtility::getDateRange($year, $month, $months);
$apiDateFrom = $dateRange['period']->getStartDate();
$apiDateTo = $dateRange['period']->getEndDate();
$hotelCode = trim($hotel->getCode() ?? ''); $hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceConfig = [ $priceConfig = [
'data' => [], 'data' => [],
@@ -157,11 +151,10 @@ class AjaxCalendarController extends ActionController
]; ];
try { try {
$priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode); $priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode, $apiDateFrom, $apiDateTo);
} catch (ApiException $e) { } catch (ApiException $e) {
$priceConfig['success'] = false; $priceConfig['success'] = false;
$priceConfig['error'] = $e->getMessage(); $priceConfig['error'] = $e->getMessage();
$this->disablePageCache();
} }
$this->view->assign('priceConfig', $priceConfig); $this->view->assign('priceConfig', $priceConfig);
@@ -171,7 +164,6 @@ class AjaxCalendarController extends ActionController
{ {
$year = $year ?? (new \DateTimeImmutable())->format('Y'); $year = $year ?? (new \DateTimeImmutable())->format('Y');
$hotelCode = trim($hotel->getCode() ?? ''); $hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceTable = [ $priceTable = [
'data' => [], 'data' => [],
@@ -184,7 +176,6 @@ class AjaxCalendarController extends ActionController
} catch (ApiException $e) { } catch (ApiException $e) {
$priceTable['success'] = false; $priceTable['success'] = false;
$priceTable['error'] = $e->getMessage(); $priceTable['error'] = $e->getMessage();
$this->disablePageCache();
} }
$this->view->assignMultiple([ $this->view->assignMultiple([
@@ -221,7 +212,7 @@ class AjaxCalendarController extends ActionController
$hotelCode, $hotelCode,
$apiDateFrom, $apiDateFrom,
$apiDateTo, $apiDateTo,
BpnConnectClient::CALENDAR_MODE_DAYS ApiClient::CALENDAR_MODE_DAYS
); );
$cachedCalendar = $this->cache->get($cacheIdentifier); $cachedCalendar = $this->cache->get($cacheIdentifier);
@@ -232,7 +223,7 @@ class AjaxCalendarController extends ActionController
$hotelCode, $hotelCode,
new \DateTimeImmutable($apiDateFrom), new \DateTimeImmutable($apiDateFrom),
new \DateTimeImmutable($apiDateTo), new \DateTimeImmutable($apiDateTo),
BpnConnectClient::CALENDAR_MODE_DAYS ApiClient::CALENDAR_MODE_DAYS
); );
$this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL); $this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL);
} }
@@ -249,43 +240,6 @@ class AjaxCalendarController extends ActionController
]); ]);
} }
/**
* Tag this page-cache entry with the hotel it renders, so the entry can be flushed for
* that hotel alone rather than by clearing the whole page cache and re-warming every
* calendar. Tags are stored alongside the entry by
* TypoScriptFrontendController::setPageCacheContent().
*
* Used manually today, when one hotel's availability needs re-fetching before its hour
* is up:
*
* typo3cms cache:flushtags ep_hotel_<code> --groups pages
*/
private function tagPageCacheForHotel(string $hotelCode): void
{
if ('' === $hotelCode) {
return;
}
if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
$GLOBALS['TSFE']->addCacheTags(['ep_hotel_' . $hotelCode]);
}
}
/**
* The calendar actions are cacheable, so an API failure would otherwise pin the error
* markup in the page cache for the full cache_period. Drop caching for this request so
* the next visitor gets a fresh attempt instead of an hour-old error message.
*
* The second argument must be true: set_no_cache() ignores the call otherwise, because
* $TYPO3_CONF_VARS['FE']['disableNoCacheParameter'] defaults to true.
*/
private function disablePageCache(): void
{
if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
$GLOBALS['TSFE']->set_no_cache('Price calendar data could not be retrieved', true);
}
}
private function buildTooltip(string $status, array $item): string private function buildTooltip(string $status, array $item): string
{ {
if ($status === 'blocked') { if ($status === 'blocked') {
@@ -0,0 +1,170 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2020 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Dto\GroupsPriceInquiry;
use EP\EpProducts\Domain\Model\GroupsPriceBoard;
use EP\EpProducts\Domain\Model\GroupsPriceOption;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\Domain\Repository\GroupsPriceOptionRepository;
use EP\EpProducts\Service\EmailService;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
class AjaxGroupsPriceController extends ActionController
{
/**
* @var GroupsPriceOptionRepository
*/
protected $groupsPriceOptionRepository;
/**
* @var EmailService
*/
protected $emailService;
public function __construct(GroupsPriceOptionRepository $groupsPriceOptionRepository, EmailService $emailService)
{
$this->groupsPriceOptionRepository = $groupsPriceOptionRepository;
$this->emailService = $emailService;
}
public function initializeProcessFormAction()
{
if ($this->request->hasArgument('groupsPriceInquiry')) {
$this->arguments->getArgument('groupsPriceInquiry')
->getPropertyMappingConfiguration()->allowAllProperties();
}
}
/**
* @param Hotel $hotel
* @param GroupsPriceInquiry $groupsPriceInquiry
* @return string
*/
public function processFormAction(Hotel $hotel, GroupsPriceInquiry $groupsPriceInquiry)
{
$mode = $groupsPriceInquiry->getMode();
$country = $hotel->getCountry()->getCode();
$effectivePax = $groupsPriceInquiry->getEffectivePax();
$nights = $groupsPriceInquiry->getNights();
if (false === in_array($mode, [GroupsPriceInquiry::MODE_BOOKING, GroupsPriceInquiry::MODE_INQUIRY])) {
$mode = GroupsPriceInquiry::MODE_BOOKING;
}
$subject = GroupsPriceInquiry::MODE_BOOKING ?
'Gruppenhaus Preisberechnung/Buchung' : 'Gruppenhaus Preisberechnung/Anfrage';
$emailOptions = [
'toEmail' => $this->settings['groupsPriceToEmail'],
'toName' => $this->settings['groupsPriceToName'],
'fromEmail' => $this->settings['groupsPriceToEmail'],
'fromName' => $this->settings['groupsPriceToName'],
'subject' => $subject,
'templateName' => 'GroupsPrice',
];
$optionPrice = function (GroupsPriceOption $option) use ($country, $effectivePax, $nights) {
$price = 'CH' === $country ? $option->getPriceChf() : $option->getPrice();
$currency = 'CH' === $country ? 'CHF' : '€';
if (GroupsPriceOption::TYPE_PER_PAX === $option->getType()) {
$price = $price * $effectivePax;
} elseif (GroupsPriceOption::TYPE_PER_NIGHT === $option->getType()) {
$price = $price * $nights;
} elseif (GroupsPriceOption::TYPE_PER_PAX_AND_NIGHT === $option->getType()) {
$price = $price * $nights * $effectivePax;
}
return $price.' '.$currency;
};
try {
$options = $this->groupsPriceOptionRepository->findByUids($groupsPriceInquiry->getOptions());
$selectedOptions = array_map(function ($option) use ($optionPrice) {
return $option->getTitle().': '.$optionPrice($option);
}, $options);
}
catch (InvalidQueryException $e) {
$selectedOptions = [];
}
$variables = [
'mode' => $mode,
'hotel' => $hotel,
'name' => $groupsPriceInquiry->getName(),
'group' => $groupsPriceInquiry->getGroup(),
'street' => $groupsPriceInquiry->getStreet(),
'postcode' => $groupsPriceInquiry->getPostcode(),
'city' => $groupsPriceInquiry->getCity(),
'email' => $groupsPriceInquiry->getEmail(),
'phone' => $groupsPriceInquiry->getPhone(),
'remarks' => $groupsPriceInquiry->getRemarks(),
'dateFrom' => $groupsPriceInquiry->getDateFrom(),
'dateTo' => $groupsPriceInquiry->getDateTo(),
'nights' => $groupsPriceInquiry->getNights(),
'pax' => $groupsPriceInquiry->getPax(),
'children' => $groupsPriceInquiry->getChildren(),
'minors' => $groupsPriceInquiry->getMinors(),
'adolescents' => $groupsPriceInquiry->getAdolescents(),
'adolescentsAge' => $hotel->getGroupsPriceAdolescentsAge(),
'options' => $selectedOptions,
'board' => $groupsPriceInquiry->getBoard(),
'summary' => $groupsPriceInquiry->getSummary(),
];
$this->emailService->send($emailOptions, $variables);
return json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR, 512);
}
/**
* @return string
*/
protected function errorAction() {
$formErrors = [];
if ($this->arguments->validate()->hasErrors()) {
foreach ($this->arguments->validate()->getFlattenedErrors() as $key => $errors) {
$parts = explode('.', $key);
$fieldName = array_pop($parts);
$errorsRaw = [];
foreach ($errors as $error) {
$translationKey = sprintf('tx_eptheme.message.%s.%s', $fieldName, $error->getCode());
$errorsRaw[] = LocalizationUtility::translate($translationKey, 'ep_theme');
}
$formErrors[$fieldName] = implode(', ', $errorsRaw);
}
}
$response['status'] = 'validation';
$response['errors'] = $formErrors;
return json_encode($response);
}
}
@@ -0,0 +1,67 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2020 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Hotel;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class GroupsPriceController extends ActionController
{
public function indexAction(Hotel $hotel)
{
// Sort configs by date to properly determine earliest date in js component
$configs = $hotel->getGroupsPriceConfigs()->toArray();
uasort($configs, function ($a, $b) {
return $a->getDateFrom() > $b->getDateFrom();
});
$mainSeasonFrom = $mainSeasonTo = null;
if (null !== $hotel->getMainSeasonFrom()) {
$mainSeasonFrom = $hotel->getMainSeasonFrom()->format('d.m.Y');
}
if (null !== $hotel->getMainSeasonTo()) {
$mainSeasonTo = $hotel->getMainSeasonTo()->format('d.m.Y');
}
$this->view->assign('hotel', $hotel);
$this->view->assign('runningCostsEUR', $this->settings['runningCostsEUR']);
$this->view->assign('runningCostsCHF', $this->settings['runningCostsCHF']);
$this->view->assign('undersubscription30EUR', $this->settings['undersubscription30EUR']);
$this->view->assign('undersubscription30CHF', $this->settings['undersubscription30CHF']);
$this->view->assign('undersubscription40EUR', $this->settings['undersubscription40EUR']);
$this->view->assign('undersubscription40CHF', $this->settings['undersubscription40CHF']);
$this->view->assign('countryCode', $hotel->getCountry()->getCode());
$this->view->assign('configs', json_encode(array_values($configs), JSON_HEX_APOS));
$this->view->assign('boards', json_encode($hotel->getGroupsPriceBoards()->toArray(), JSON_HEX_APOS));
$this->view->assign('options', json_encode($hotel->getGroupsPriceOptions()->toArray(), JSON_HEX_APOS));
$this->view->assign('mainSeasonFrom', $mainSeasonFrom);
$this->view->assign('mainSeasonTo', $mainSeasonTo);
$this->view->assign('adolescentsAge', $hotel->getGroupsPriceAdolescentsAge() ?? 0);
}
}
@@ -36,7 +36,6 @@ use EP\EpProducts\Service\ContingentDataService;
use EP\EpProducts\Service\DateService; use EP\EpProducts\Service\DateService;
use EP\EpProducts\Service\FilterService; use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\HotelDataService; use EP\EpProducts\Service\HotelDataService;
use EP\EpProducts\Service\PickupOverviewService;
use EP\EpProducts\Service\ProductDataService; use EP\EpProducts\Service\ProductDataService;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
@@ -83,11 +82,6 @@ class ProductController extends ActionController
*/ */
private $infoboxRepository; private $infoboxRepository;
/**
* @var PickupOverviewService
*/
private $pickupOverviewService;
/** /**
* @param ProductRepository $productRepository * @param ProductRepository $productRepository
* @param ProductDataService $productDataService * @param ProductDataService $productDataService
@@ -97,7 +91,6 @@ class ProductController extends ActionController
* @param DateService $dateService * @param DateService $dateService
* @param FilterService $filterService * @param FilterService $filterService
* @param InfoboxRepository $infoboxRepository * @param InfoboxRepository $infoboxRepository
* @param PickupOverviewService $pickupOverviewService
*/ */
public function __construct( public function __construct(
ProductRepository $productRepository, ProductRepository $productRepository,
@@ -107,8 +100,7 @@ class ProductController extends ActionController
ContingentDataService $contingentDataService, ContingentDataService $contingentDataService,
DateService $dateService, DateService $dateService,
FilterService $filterService, FilterService $filterService,
InfoboxRepository $infoboxRepository, InfoboxRepository $infoboxRepository
PickupOverviewService $pickupOverviewService
) { ) {
$this->productRepository = $productRepository; $this->productRepository = $productRepository;
$this->productDataService = $productDataService; $this->productDataService = $productDataService;
@@ -118,7 +110,6 @@ class ProductController extends ActionController
$this->dateService = $dateService; $this->dateService = $dateService;
$this->filterService = $filterService; $this->filterService = $filterService;
$this->infoboxRepository = $infoboxRepository; $this->infoboxRepository = $infoboxRepository;
$this->pickupOverviewService = $pickupOverviewService;
} }
/** /**
@@ -205,7 +196,6 @@ class ProductController extends ActionController
'referringUrl' => $backlinkUrl, 'referringUrl' => $backlinkUrl,
'backlinkLabel' => $backLinkLabel, 'backlinkLabel' => $backLinkLabel,
'nonBookableInfo' => $nonBookableInfo, 'nonBookableInfo' => $nonBookableInfo,
'pickupsOverview' => $this->pickupOverviewService->getOverview($product, $hotel),
]); ]);
} }
@@ -87,16 +87,12 @@ class SearchController extends ActionController
public function initializeSearchresultAction() public function initializeSearchresultAction()
{ {
// Process encoded filtersettings from static search result. // Process encoded filtersettings from static search result
// Explicit filter settings win: they carry the visitor's current selection,
// whereas a stale 'f' riding along on a link would silently replace it.
$encodedFilterSettings = GeneralUtility::_GET('f'); $encodedFilterSettings = GeneralUtility::_GET('f');
if ($encodedFilterSettings && !$this->request->hasArgument('filterSettings')) { if ($encodedFilterSettings) {
$filterSettingsEncoder = new FilterSettingsEncoder(); $filterSettingsEncoder = new FilterSettingsEncoder();
$filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings); $filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings);
if (count($filterSettings) > 0) { $this->request->setArgument('filterSettings', $filterSettings);
$this->request->setArgument('filterSettings', $filterSettings);
}
} }
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'], true); $forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'], true);
$this->processFilterSettingsArgument($forcedConceptUids); $this->processFilterSettingsArgument($forcedConceptUids);
@@ -56,7 +56,7 @@ class FacebookCatalogCsvFormatter
$product['images']['image'][0], $product['images']['image'][0],
$product['link'], $product['link'],
$product['name'], $product['name'],
sprintf('%.2f EUR', $minPrice), $minPrice . ' EUR',
'brand-epreisen' 'brand-epreisen'
]; ];
} }
@@ -48,7 +48,7 @@ class GoogleBusinessFeedCsvFormatter
$product['name'], $product['name'],
$product['link'], $product['link'],
$product['images']['image'][0], $product['images']['image'][0],
sprintf('%.2f EUR', $minPrice), $minPrice . '.00 EUR',
]; ];
} }
} }
@@ -114,20 +114,15 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
*/ */
protected $skipassIncluded = false; protected $skipassIncluded = false;
/**
* @var bool
*/
protected $groupTravel = false;
/** /**
* @var bool * @var bool
*/ */
protected $busAvailable = false; protected $busAvailable = false;
/** /**
* @var float * @var int
*/ */
protected $busPrice = 0.0; protected $busPrice = 0;
/** /**
* @var string * @var string
@@ -150,19 +145,19 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
protected $pickups = ''; protected $pickups = '';
/** /**
* @var float * @var int
*/ */
protected $discount = 0.0; protected $discount = 0;
/** /**
* @var float * @var int
*/ */
protected $minPrice = 0.0; protected $minPrice = 0;
/** /**
* @var float * @var int
*/ */
protected $pseudoPrice = 0.0; protected $pseudoPrice = 0;
/** /**
* @var int * @var int
@@ -426,22 +421,6 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
$this->skipassIncluded = $skipassIncluded; $this->skipassIncluded = $skipassIncluded;
} }
/**
* @return bool
*/
public function getGroupTravel()
{
return $this->groupTravel;
}
/**
* @param bool $groupTravel
*/
public function setGroupTravel($groupTravel)
{
$this->groupTravel = $groupTravel;
}
/** /**
* @return bool * @return bool
*/ */
@@ -459,7 +438,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return float * @return int
*/ */
public function getBusPrice() public function getBusPrice()
{ {
@@ -467,7 +446,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param float $busPrice * @param int $busPrice
*/ */
public function setBusPrice($busPrice) public function setBusPrice($busPrice)
{ {
@@ -554,21 +533,21 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return float * @return int
*/ */
public function getDiscount() { public function getDiscount() {
return $this->discount; return $this->discount;
} }
/** /**
* @param float $discount * @param int $discount
*/ */
public function setDiscount($discount) { public function setDiscount($discount) {
$this->discount = $discount; $this->discount = $discount;
} }
/** /**
* @return float * @return int
*/ */
public function getMinPrice() public function getMinPrice()
{ {
@@ -576,7 +555,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param float $minPrice * @param int $minPrice
*/ */
public function setMinPrice($minPrice) public function setMinPrice($minPrice)
{ {
@@ -584,7 +563,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return float * @return int
*/ */
public function getPseudoPrice() public function getPseudoPrice()
{ {
@@ -592,7 +571,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param float $pseudoPrice * @param int $pseudoPrice
*/ */
public function setPseudoPrice($pseudoPrice) public function setPseudoPrice($pseudoPrice)
{ {
@@ -27,7 +27,6 @@ namespace EP\EpProducts\Domain\Model;
* This copyright notice MUST APPEAR in all copies of the script! * This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/ ***************************************************************/
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference; use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity; use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage; use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
@@ -1254,20 +1253,4 @@ class Hotel extends AbstractEntity implements TeaserInterface
{ {
$this->distanceLift = $distanceLift; $this->distanceLift = $distanceLift;
} }
public function getIcons(): array
{
return HotelIconUtility::getIcons([
'beds_count' => $this->bedsCount,
'parking_count' => $this->parkingCount,
'altitude' => $this->altitude,
'distance_bus' => $this->distanceBus,
'distance_lift' => $this->distanceLift,
'shower' => $this->shower,
'partyroom' => $this->partyroom,
'gamesroom' => $this->gamesroom,
'sauna' => $this->sauna,
'wifi' => $this->wifi,
]);
}
} }
@@ -30,17 +30,12 @@ namespace EP\EpProducts\Domain\Model;
class Room class Room
{ {
const ROOM_TYPE_UNCLASSIFIED = 0;
const ROOM_TYPE_FOR_ONE = 1; const ROOM_TYPE_FOR_ONE = 1;
const ROOM_TYPE_FOR_TWO = 2; const ROOM_TYPE_FOR_TWO = 2;
const ROOM_TYPE_FOR_THREE = 3; const ROOM_TYPE_FOR_THREE = 3;
const ROOM_TYPE_FOR_FOUR = 4; const ROOM_TYPE_FOR_FOUR = 4;
const ROOM_TYPE_FOR_FIVE = 5; const ROOM_TYPE_WITH_OTHERS = 5;
const ROOM_TYPE_FOR_SIX = 6; const ROOM_TYPE_OTHER = 6;
const ROOM_TYPE_FOR_SEVEN = 7;
const ROOM_TYPE_FOR_EIGHT = 8;
const ROOM_TYPE_FOR_NINE_PLUS = 9;
const ROOM_TYPE_WITH_OTHERS = 10;
const ROOM_STATUS_AVAILABLE = 1; const ROOM_STATUS_AVAILABLE = 1;
const ROOM_STATUS_ON_REQUEST = 2; const ROOM_STATUS_ON_REQUEST = 2;
@@ -0,0 +1,75 @@
<?php
namespace EP\EpProducts\Domain\Model;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
class RoomMapping extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
/**
* @var string
*/
protected $code = '';
/**
* @var int
*/
protected $type = 0;
/**
* @return string
*/
public function getCode()
{
return $this->code;
}
/**
* @param string $code
*/
public function setCode($code)
{
$this->code = $code;
}
/**
* @return int
*/
public function getType()
{
return $this->type;
}
/**
* @param int $type
*/
public function setType($type)
{
$this->type = $type;
}
}
@@ -3,7 +3,6 @@
namespace EP\EpProducts\Domain\Repository; namespace EP\EpProducts\Domain\Repository;
use EP\EpProducts\Domain\Model\Product; use EP\EpProducts\Domain\Model\Product;
use TYPO3\CMS\Core\Database\Connection;
class DateRepository extends AbstractRepository class DateRepository extends AbstractRepository
{ {
@@ -19,27 +18,4 @@ class DateRepository extends AbstractRepository
->execute() ->execute()
; ;
} }
/**
* @return string[] serialized pickups of each upcoming date, latest first
*/
public function findPickupsForProductAndHotel(int $productUid, int $hotelUid): array
{
$qb = $this->getDbConnection()->createQueryBuilder();
return $qb
->select('date.pickups')
->from('tx_epproducts_domain_model_date', 'date')
->where(
$qb->expr()->eq('date.product', $qb->createNamedParameter($productUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.hotel', $qb->createNamedParameter($hotelUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.group_travel', $qb->createNamedParameter(0, Connection::PARAM_INT)),
'date.date_start >= NOW()'
)
->orderBy('date.date_start', 'DESC')
->addOrderBy('date.uid', 'DESC')
->execute()
->fetchFirstColumn()
;
}
} }
@@ -76,11 +76,9 @@ class ProductRepository extends AbstractRepository
'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded', 'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded',
'date.bus_available as busAvailable', 'date.new as new', 'date.bus_available as busAvailable', 'date.new as new',
'date.min_price as minPrice', 'date.nights as nights', 'date.available as available', 'date.min_price as minPrice', 'date.nights as nights', 'date.available as available',
'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton' 'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton',
'room.type as roomType'
) )
// The GROUP BY below collapses many room rows per group; an arbitrary
// room.type would hide most types from the filter facet.
->addSelectLiteral('GROUP_CONCAT(DISTINCT room.type) as roomTypes')
->from('tx_epproducts_domain_model_date', 'date') ->from('tx_epproducts_domain_model_date', 'date')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid') ->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid') ->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid')
@@ -210,7 +208,6 @@ class ProductRepository extends AbstractRepository
'date.uid as dateUid', 'date.date_start as dateStart', 'date.date_end as dateEnd', 'date.uid as dateUid', 'date.date_start as dateStart', 'date.date_end as dateEnd',
'date.bus_pro_id as dateBusProId', 'date.hotel as hotelUid', 'date.hotel_bus_pro_id as hotelBusProId', 'date.bus_pro_id as dateBusProId', 'date.hotel as hotelUid', 'date.hotel_bus_pro_id as hotelBusProId',
'date.bus_included as dateBusIncluded', 'date.skipass_included as dateSkiPassIncluded', 'date.bus_included as dateBusIncluded', 'date.skipass_included as dateSkiPassIncluded',
'date.group_travel as dateGroupTravel',
'date.season as season', 'date.product_detail_page as productDetailPage', 'date.season as season', 'date.product_detail_page as productDetailPage',
'room.uid as roomUid', 'room.bus_pro_id as roomBusProId', 'room.name as roomName', 'room.uid as roomUid', 'room.bus_pro_id as roomBusProId', 'room.name as roomName',
'room.price as roomPrice', 'date.discount as roomDiscount', 'date.bus_price as busPrice', 'room.price as roomPrice', 'date.discount as roomDiscount', 'date.bus_price as busPrice',
@@ -274,7 +271,6 @@ class ProductRepository extends AbstractRepository
'date.discount as dateDiscount', 'date.bus_price as busPrice', 'date.nights as dateNights', 'date.discount as dateDiscount', 'date.bus_price as busPrice', 'date.nights as dateNights',
'date.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded', 'date.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded',
'date.services_general as dateServicesGeneral', 'date.skipass_included as dateSkiPassIncluded', 'date.services_general as dateServicesGeneral', 'date.skipass_included as dateSkiPassIncluded',
'date.group_travel as dateGroupTravel',
'date.bus_pro_id as dateBusProId', 'date.hotel_bus_pro_id as hotelBusProId', 'date.bus_pro_id as dateBusProId', 'date.hotel_bus_pro_id as hotelBusProId',
'date.available as available', 'date.product_detail_page as productDetailPage', 'date.available as available', 'date.product_detail_page as productDetailPage',
) )
@@ -0,0 +1,31 @@
<?php
namespace EP\EpProducts\Domain\Repository;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
class RoomMappingRepository extends AbstractRepository
{}
@@ -44,7 +44,7 @@ class Date
$date['@id'] = $array['dateBusProId']; $date['@id'] = $array['dateBusProId'];
$date['from'] = $array['dateStart']; $date['from'] = $array['dateStart'];
$date['to'] = $array['dateEnd']; $date['to'] = $array['dateEnd'];
$date['minprice'] = (float) $array['dateMinPrice']; $date['minprice'] = $array['dateMinPrice'];
$date['nights'] = $array['dateNights']; $date['nights'] = $array['dateNights'];
$date['skipassincluded'] = $array['dateSkiPassIncluded']; $date['skipassincluded'] = $array['dateSkiPassIncluded'];
$date['busincluded'] = $array['dateBusIncluded']; $date['busincluded'] = $array['dateBusIncluded'];
@@ -40,8 +40,8 @@ class Room
$room['@pax'] = $array['roomPax']; $room['@pax'] = $array['roomPax'];
$room['@code'] = $array['roomCode']; $room['@code'] = $array['roomCode'];
$room['name'] = $array['roomName']; $room['name'] = $array['roomName'];
$room['price'] = (float) $array['roomPrice']; $room['price'] = $array['roomPrice'];
$room['busprice'] = (float) $array['busPrice']; $room['busprice'] = $array['busPrice'];
$room['nights'] = $array['roomNights']; $room['nights'] = $array['roomNights'];
$room['available'] = $array['roomAvailable']; $room['available'] = $array['roomAvailable'];
@@ -1,244 +0,0 @@
<?php
namespace EP\EpProducts\EpTeam;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The MyE&P Team application (Symfony). Separate from EP\EpProducts\MyEP\ApiClient on purpose:
* this is a different application on its own host, authenticated with a shared key in a header
* instead of the OAuth client-credentials flow MyE&P uses, so it shares neither the base URI nor
* the token handling.
*/
class ApiClient implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
/**
* Status codes the endpoint answers a stored submission with. A conflict is included for the
* dedupe the team application may add later - it is not returned today - because a
* double-clicked submit button is not something to report.
*/
private const ACCEPTED_STATUS_CODES = [
Response::HTTP_OK,
Response::HTTP_CREATED,
Response::HTTP_ACCEPTED,
Response::HTTP_NO_CONTENT,
Response::HTTP_CONFLICT,
];
/**
* How much of a rejected response is carried into the exception message. A 400 names the
* offending field in its body, which is the only way to tell from a log why a submission
* was refused.
*/
private const ERROR_BODY_EXCERPT_LENGTH = 500;
/**
* Shared, like the MyE&P client: Symfony's CurlHttpClient keeps its connection pool and TLS
* session cache per instance. The API key is a constant, so unlike a bearer token it can be
* a client default header and the instance can live as long as the request.
*/
private static ?HttpClientInterface $httpClient = null;
private ?array $configuration = null;
/**
* Posts a submitted TYPO3 form to POST /api/application-forms.
*
* @throws ApiException
*/
public function submitApplicationForm(array $payload): void
{
$uri = 'api/application-forms';
// 'json' also sets the Content-Type the endpoint requires - without it the body is not
// read as JSON and the answer is a 400.
$response = $this->request('POST', $uri, [
'json' => $payload,
]);
$statusCode = $this->readStatusCode($response, 'POST', $uri);
if (false === in_array($statusCode, self::ACCEPTED_STATUS_CODES, true)) {
$message = sprintf(
'EP Team API returned unexpected status %d for POST %s: %s',
$statusCode,
$uri,
$this->readErrorBody($response)
);
$this->logWarning($message);
throw new ApiException($message, $statusCode);
}
$this->logAcceptedSubmission($response, $payload);
}
/**
* @throws ApiException
*/
private function request(string $method, string $uri, array $options): ResponseInterface
{
try {
return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) {
$message = sprintf('EP Team API transport error for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* Reading the status completes the transfer, so this is also what turns the lazy response
* returned by the client into a finished request.
*
* @throws ApiException
*/
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
{
try {
return $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('EP Team API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
private function readErrorBody(ResponseInterface $response): string
{
try {
$body = trim($response->getContent(false));
} catch (TransportExceptionInterface $e) {
return '<no response body>';
}
if ('' === $body) {
return '<empty response body>';
}
if (mb_strlen($body) > self::ERROR_BODY_EXCERPT_LENGTH) {
$body = mb_substr($body, 0, self::ERROR_BODY_EXCERPT_LENGTH) . '…';
}
return $body;
}
/**
* The submission uuid the endpoint returns is what makes a support question ("did our
* application arrive?") answerable from the TYPO3 log alone. Reading the body also drains
* the response, which returns the connection to the pool instead of cancelling the transfer.
* Deliberately without any of the submitted values: these payloads are personal data.
*/
private function logAcceptedSubmission(ResponseInterface $response, array $payload): void
{
try {
$data = $response->toArray(false);
} catch (\Throwable $e) {
// A body that is absent (204) or not JSON costs nothing here - the submission is
// stored either way, which the asserted status has already established.
return;
}
if (null !== $this->logger) {
$this->logger->info('Application form submission accepted by the EP Team API.', [
'form' => $payload['form'] ?? null,
'pageUid' => $payload['pageUid'] ?? null,
'uuid' => $data['uuid'] ?? null,
]);
}
}
/**
* @throws ApiException
*/
private function getHttpClient(): HttpClientInterface
{
if (null !== static::$httpClient) {
return static::$httpClient;
}
$config = $this->getConfiguration();
$baseUrl = trim((string)($config['epTeamApiBaseUrl'] ?? ''));
$apiKey = trim((string)($config['epTeamApiKey'] ?? ''));
if ('' === $baseUrl) {
throw new ApiException('EP Team API base URL is not configured.');
}
if ('' === $apiKey) {
throw new ApiException('EP Team API key is not configured.');
}
if (0 !== strpos($baseUrl, 'https://')) {
// The application answers an http request with a 301 to the https URL, and this
// client does not follow redirects, so a plain http base URL fails every submission.
$this->logWarning(sprintf('EP Team API base URL is not https (%s); submissions will fail on the redirect.', $baseUrl));
}
$options = [
'headers' => [
'X-Api-Key' => $apiKey,
],
];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request - and this call happens
// inside the visitor's form submission, with the visitor waiting for it. The defaults
// live in code rather than in ext_conf_template.txt: ExtensionConfiguration::get()
// returns the stored configuration verbatim and only syncs the template when an
// extension has no configuration at all, so a newly added template key is absent until
// someone saves extension configuration by hand. Falling back to 0 there would silently
// mean "no timeout" - the opposite of what this guard is for. An explicit 0 still
// disables.
$timeout = (int)($config['epTeamApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['epTeamApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return static::$httpClient = HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
}
private function logError(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->error($message, $context);
}
}
private function logWarning(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->warning($message, $context);
}
}
}
@@ -1,7 +0,0 @@
<?php
namespace EP\EpProducts\EpTeam;
class ApiException extends \RuntimeException
{
}
@@ -1,45 +0,0 @@
<?php
namespace EP\EpProducts\Http;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
/**
* Checks the shared key of the CMS backchannel endpoints (content feed, CMS index).
*
* An empty or missing `cmsApiKey` denies every request. ExtensionConfiguration::get() returns the
* stored configuration verbatim, so a newly added key is absent until someone saves the extension
* configuration in the backend - defaulting to "allow" there would publish the whole catalogue.
*/
class ApiKeyAuthenticator
{
/**
* @var ExtensionConfiguration
*/
private $extensionConfiguration;
public function __construct(ExtensionConfiguration $extensionConfiguration)
{
$this->extensionConfiguration = $extensionConfiguration;
}
public function isAuthorized(ServerRequestInterface $request): bool
{
$configured = (string) ($this->extensionConfiguration->get('ep_products')['cmsApiKey'] ?? '');
if ('' === $configured) {
return false;
}
// Header first: query-string keys end up in access logs. `key` stays accepted for parity
// with the existing /api/hotel and /api/product calls.
$provided = $request->getHeaderLine('X-Api-Key');
if ('' === $provided) {
$provided = (string) ($request->getQueryParams()['key'] ?? '');
}
return hash_equals($configured, $provided);
}
}
@@ -2,10 +2,7 @@
namespace EP\EpProducts\Middleware; namespace EP\EpProducts\Middleware;
use EP\EpProducts\ContentFeed\ContentFeedApi;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use EP\EpProducts\Service\MyEpImageService; use EP\EpProducts\Service\MyEpImageService;
use EP\EpProducts\Utility\HotelIconUtility;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
@@ -43,32 +40,18 @@ class ApiMiddleware implements MiddlewareInterface
*/ */
private $imageService; private $imageService;
/**
* @var ContentFeedApi
*/
private $contentFeedApi;
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
public function __construct( public function __construct(
MyEpImageService $hotelImageService, MyEpImageService $hotelImageService,
MyEpImageService $productImageService, MyEpImageService $productImageService,
MyEpImageService $regionImageService, MyEpImageService $regionImageService,
FileRepository $fileRepository, FileRepository $fileRepository,
ImageService $imageService, ImageService $imageService
ContentFeedApi $contentFeedApi,
ApiKeyAuthenticator $authenticator
) { ) {
$this->hotelImageService = $hotelImageService; $this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService; $this->productImageService = $productImageService;
$this->regionImageService = $regionImageService; $this->regionImageService = $regionImageService;
$this->fileRepository = $fileRepository; $this->fileRepository = $fileRepository;
$this->imageService = $imageService; $this->imageService = $imageService;
$this->contentFeedApi = $contentFeedApi;
$this->authenticator = $authenticator;
} }
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -83,17 +66,17 @@ class ApiMiddleware implements MiddlewareInterface
return $this->handleProductRequest($request); return $this->handleProductRequest($request);
} }
if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) {
return $this->contentFeedApi->handle($request);
}
return $handler->handle($request); return $handler->handle($request);
} }
private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface
{ {
if (!$this->authenticator->isAuthorized($request)) { // This is currently fake, we only check for existence. Since we are communicating
return new JsonResponse(['message' => 'Invalid or missing api key'], 403); // on the backchannel only it's not much of a problem.
$apiKey = $request->getQueryParams()['key'] ?? null;
if (null === $apiKey) {
return new JsonResponse(['message' => 'No api key provided'], 403);
} }
$hotelCode = $request->getQueryParams()['hotel'] ?? null; $hotelCode = $request->getQueryParams()['hotel'] ?? null;
@@ -113,8 +96,12 @@ class ApiMiddleware implements MiddlewareInterface
private function handleProductRequest(ServerRequestInterface $request): ResponseInterface private function handleProductRequest(ServerRequestInterface $request): ResponseInterface
{ {
if (!$this->authenticator->isAuthorized($request)) { // This is currently fake, we only check for existence. Since we are communicating
return new JsonResponse(['message' => 'Invalid or missing api key'], 403); // on the backchannel only it's not much of a problem.
$apiKey = $request->getQueryParams()['key'] ?? null;
if (null === $apiKey) {
return new JsonResponse(['message' => 'No api key provided'], 403);
} }
$productCode = $request->getQueryParams()['product'] ?? null; $productCode = $request->getQueryParams()['product'] ?? null;
@@ -230,6 +217,40 @@ class ApiMiddleware implements MiddlewareInterface
$images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid'])); $images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid']));
// map fields to icon ids and labels
$icons = [];
if (($hotel['beds_count'] ?? 0) > 0) {
$icons['bed'] = sprintf('%d Betten', $hotel['beds_count']);
}
if (($hotel['parking_count'] ?? 0) > 0) {
$icons['parking'] = sprintf('%d Parkplätze', $hotel['parking_count']);
}
if ($hotel['altitude'] ?? null) {
$icons['mountain'] = $hotel['altitude'];
}
if ($hotel['distance_bus'] ?? null) {
$icons['bus'] = $hotel['distance_bus'];
}
if ($hotel['distance_lift'] ?? null) {
$icons['lift'] = $hotel['distance_lift'];
}
if ($hotel['shower'] ?? null) {
$icons['shower'] = $hotel['shower'];
}
if ($hotel['partyroom'] ?? false) {
$icons['party'] = 'Partyzimmer';
}
if ($hotel['gamesroom'] ?? false) {
$icons['dice'] = 'Spielezimmer';
}
if ($hotel['sauna'] ?? false) {
$icons['sauna'] = 'Sauna';
}
if ($hotel['wifi'] ?? false) {
$icons['wifi'] = 'WiFi';
}
return [ return [
'name' => $hotel['name'], 'name' => $hotel['name'],
'code' => $hotel['code'], 'code' => $hotel['code'],
@@ -239,7 +260,7 @@ class ApiMiddleware implements MiddlewareInterface
'additional_information' => $hotel['additional_information'], 'additional_information' => $hotel['additional_information'],
'address' => $hotel['address'], 'address' => $hotel['address'],
'images' => $images, 'images' => $images,
'icons' => HotelIconUtility::getIcons($hotel), 'icons' => $icons,
'region' => $this->fetchRegionData((int) $hotel['region']), 'region' => $this->fetchRegionData((int) $hotel['region']),
]; ];
} }
@@ -1,65 +0,0 @@
<?php
namespace EP\EpProducts\Middleware;
use EP\EpProducts\ContentFeed\ContentFeedApi;
use EP\EpProducts\ContentFeed\ContentFeedConfiguration;
use EP\EpProducts\ContentFeed\ManifestBuilder;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\ErrorController;
/**
* Keeps the chatbot content-only page type (an unstyled copy of every page) off the public web:
* only requests carrying the CMS API key get it.
*
* Runs once the page type is known and before TSFE, so a page-cache hit cannot bypass it.
* staticfilecache never stores the type (`tx_staticfilecache.disableCache`); if it did, .htaccess
* would serve the file without this check ever running.
*/
class ContentPageTypeGuardMiddleware implements MiddlewareInterface
{
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
public function __construct(ApiKeyAuthenticator $authenticator, ContentFeedConfiguration $configuration)
{
$this->authenticator = $authenticator;
$this->configuration = $configuration;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$routing = $request->getAttribute('routing');
if (!$routing instanceof PageArguments
|| (string) ManifestBuilder::CONTENT_PAGE_TYPE !== $routing->getPageType()
) {
return $handler->handle($request);
}
if (!$this->authenticator->isAuthorized($request)) {
// 404, not 403: an unauthenticated caller should not learn the type exists.
return GeneralUtility::makeInstance(ErrorController::class)
->pageNotFoundAction($request, 'The requested page does not exist');
}
if (!$this->configuration->isEnabled()) {
return ContentFeedApi::disabledResponse();
}
return $handler->handle($request);
}
}
@@ -16,8 +16,6 @@ use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -25,50 +23,8 @@ class ApiClient implements LoggerAwareInterface
{ {
use LoggerAwareTrait; use LoggerAwareTrait;
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
/**
* Cache identifier for the shared OAuth access token. The token is valid for the whole
* installation, so caching it here saves one round trip per request that would otherwise
* have to re-authenticate before it can fetch anything.
*/
private const ACCESS_TOKEN_CACHE_IDENTIFIER = 'myep_api_access_token';
/**
* Safety margin in seconds. A token that is about to expire is treated as expired so it
* cannot lapse between the cache read and the API call that uses it.
*/
private const ACCESS_TOKEN_EXPIRY_MARGIN = 60;
/**
* Fallback lifetime for tokens that do not report an expiry.
*/
private const ACCESS_TOKEN_DEFAULT_TTL = 300;
/**
* Status codes that mean the token was rejected rather than the request being wrong.
*/
private const UNAUTHORIZED_STATUS_CODES = [Response::HTTP_UNAUTHORIZED, Response::HTTP_FORBIDDEN];
private static ?AccessToken $accessToken = null; private static ?AccessToken $accessToken = null;
/**
* Shared on purpose. Symfony's CurlHttpClient keeps its connection pool and TLS session
* cache in a CurlClientState owned by the instance, so building a client per call throws
* both away and forces a fresh DNS lookup, TCP connect and TLS handshake every time -
* TravelinfoController alone makes two calls per page render. The bearer token is passed
* per request rather than as a client default so one instance can outlive one token.
*/
private static ?HttpClientInterface $httpClient = null;
private ?FrontendInterface $cache = null;
private ?array $configuration = null;
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -81,8 +37,7 @@ class ApiClient implements LoggerAwareInterface
} }
try { try {
// The API reports Europe/Berlin local time without an offset. return new \DateTimeImmutable($data['timestamp']);
return new \DateTimeImmutable($data['timestamp'], new \DateTimeZone('Europe/Berlin'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
$message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage()); $message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]); $this->logError($message, ['exception' => $e]);
@@ -90,22 +45,6 @@ class ApiClient implements LoggerAwareInterface
} }
} }
/**
* One page of the product export: one row per travel date × hotel in `data`,
* and `meta` describing the whole export.
*
* @throws ApiException
*/
public function getProductExport(int $page, int $perPage): array
{
return $this->requestJson('GET', 'exports/products', [
'query' => [
'page' => $page,
'perPage' => $perPage,
],
]);
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -131,10 +70,7 @@ class ApiClient implements LoggerAwareInterface
'json' => $addressData, 'json' => $addressData,
]); ]);
// A conflict means the address is already registered, which is the normal answer to a $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT], 'POST', 'contactform');
// resubmitted contact form and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform');
$this->drainResponse($response);
} }
/** /**
@@ -178,11 +114,13 @@ class ApiClient implements LoggerAwareInterface
/** /**
* @throws ApiException * @throws ApiException
*/ */
public function getPriceConfigData(string $hotelCode): array public function getPriceConfigData(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
{ {
return $this->requestJson('GET', 'contingents/calendar', [ return $this->requestJson('GET', 'contingents/calendar', [
'query' => [ 'query' => [
'hotelCode' => $hotelCode, 'hotelCode' => $hotelCode,
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
], ],
]); ]);
} }
@@ -217,30 +155,6 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function request(string $method, string $uri, array $options = []): ResponseInterface private function request(string $method, string $uri, array $options = []): ResponseInterface
{ {
$response = $this->sendRequest($method, $uri, $options);
if (false === in_array($this->readStatusCode($response, $method, $uri), self::UNAUTHORIZED_STATUS_CODES, true)) {
return $response;
}
// The token outlives a single PHP request, so one the API has stopped accepting - after
// a MyEP restart or a revoked client - would keep failing every call for the rest of its
// cached lifetime unless it is actively discarded here.
$this->logWarning(sprintf('MyEP API rejected the cached access token for %s %s, re-authenticating once', $method, $uri));
$this->discardAccessToken();
return $this->sendRequest($method, $uri, $options);
}
/**
* @throws ApiException
*/
private function sendRequest(string $method, string $uri, array $options): ResponseInterface
{
// Resolved here rather than baked into the client's default options so that the shared
// client survives a token change, and so the retry above picks up the fresh token.
$options['auth_bearer'] = $this->getAccessToken($this->getConfiguration())->getToken();
try { try {
return $this->getHttpClient()->request($method, $uri, $options); return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) { } catch (TransportExceptionInterface $e) {
@@ -257,7 +171,13 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void
{ {
$statusCode = $this->readStatusCode($response, $method, $uri); try {
$statusCode = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
if (true === in_array($statusCode, $expectedStatusCodes, true)) { if (true === in_array($statusCode, $expectedStatusCodes, true)) {
return; return;
@@ -269,39 +189,6 @@ class ApiClient implements LoggerAwareInterface
throw new ApiException($message, $statusCode); throw new ApiException($message, $statusCode);
} }
/**
* Reading the status completes the transfer, so this is also what turns the lazy response
* returned by the client into a finished request. Symfony keeps the status on the response,
* so calling this twice costs nothing.
*
* @throws ApiException
*/
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
{
try {
return $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* Reads and discards a body nobody else is going to look at. Destroying an unconsumed
* response cancels the transfer, which closes the connection instead of returning it to
* the pool, so the next call would have to handshake again.
*/
private function drainResponse(ResponseInterface $response): void
{
try {
$response->getContent(false);
} catch (TransportExceptionInterface $e) {
// The status has already been asserted, so a failure here costs nothing but the
// connection reuse this method exists for.
}
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -322,58 +209,14 @@ class ApiClient implements LoggerAwareInterface
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
if (null !== static::$httpClient) { $config = GeneralUtility::makeInstance(ExtensionConfiguration::class)
return static::$httpClient; ->get('ep_products');
}
$config = $this->getConfiguration(); static::$accessToken = $this->getAccessToken($config);
$options = []; return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [
'auth_bearer' => static::$accessToken->getToken(),
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s), ]);
// so a stalled API pins a PHP worker for a minute per request. The defaults live in
// code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the
// stored configuration verbatim and only syncs the template when an extension has no
// configuration at all, so a newly added template key is absent until someone saves
// extension configuration by hand. Falling back to 0 there would silently mean "no
// timeout" - the opposite of what this guard is for. An explicit 0 still disables.
$timeout = (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['myEpApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return static::$httpClient = HttpClient::createForBaseUri($config['myEpApiBaseUrl'], $options);
}
/**
* Resolved lazily rather than injected: this class is also instantiated through Extbase's
* legacy object container (EP\EpTheme\Form\BpnApiFinisher asks for it via injectApiClient),
* and that container resolves constructor arguments by reflection, mapping an interface to
* a class name by stripping the "Interface" suffix (Container::getImplementationClassName()).
* A constructor typed against FrontendInterface therefore fatals there with
* "Class TYPO3\CMS\Core\Cache\Frontend\Frontend does not exist".
*/
private function getCache(): FrontendInterface
{
if (null === $this->cache) {
$this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('ep_products_cache');
}
return $this->cache;
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
} }
/** /**
@@ -381,25 +224,12 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function getAccessToken(array $config): AccessToken private function getAccessToken(array $config): AccessToken
{ {
if (true === $this->isTokenUsable(static::$accessToken)) { if (null !== static::$accessToken && false === static::$accessToken->hasExpired()) {
return static::$accessToken; return static::$accessToken;
} }
// The static above only lives for one PHP request, so without this second level every
// single request would re-authenticate before it could fetch any data.
$cachedToken = $this->getCache()->get(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
if (is_array($cachedToken) && isset($cachedToken['access_token'])) {
$token = new AccessToken($cachedToken);
if (true === $this->isTokenUsable($token)) {
static::$accessToken = $token;
return static::$accessToken;
}
}
try { try {
static::$accessToken = $this->normalizeToken($this->getProvider($config)->getAccessToken('client_credentials')); static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials');
$this->cacheAccessToken(static::$accessToken);
} catch (IdentityProviderException $e) { } catch (IdentityProviderException $e) {
$message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage()); $message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]); $this->logError($message, ['exception' => $e]);
@@ -417,69 +247,9 @@ class ApiClient implements LoggerAwareInterface
return static::$accessToken; return static::$accessToken;
} }
/**
* AccessToken::hasExpired() throws for a token that reports no expiry, so it cannot be used
* as the check - a token without one is simply treated as unusable and replaced. The same
* margin as the cache lifetime is applied on read as well, so a token cannot lapse between
* this check and the API call that uses it, whichever node wrote the cache entry.
*/
private function isTokenUsable(?AccessToken $token): bool
{
if (null === $token) {
return false;
}
$expires = $token->getExpires();
if (null === $expires) {
return false;
}
return $expires - self::ACCESS_TOKEN_EXPIRY_MARGIN > time();
}
/**
* A token that reports no expiry cannot be validated on read - isTokenUsable() rejects it
* and AccessToken::hasExpired() throws for it - so it is given the fallback lifetime here,
* once, and the rest of the class can treat every token the same way.
*/
private function normalizeToken(AccessToken $token): AccessToken
{
if (null !== $token->getExpires()) {
return $token;
}
return new AccessToken($token->jsonSerialize() + ['expires_in' => self::ACCESS_TOKEN_DEFAULT_TTL]);
}
private function discardAccessToken(): void
{
static::$accessToken = null;
$this->getCache()->remove(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
}
private function cacheAccessToken(AccessToken $token): void
{
// normalizeToken() has already given a token without a reported expiry the fallback
// lifetime, so there is always a real expiry to subtract the margin from here.
$lifetime = $token->getExpires() - time() - self::ACCESS_TOKEN_EXPIRY_MARGIN;
if ($lifetime <= 0) {
return;
}
$this->getCache()->set(self::ACCESS_TOKEN_CACHE_IDENTIFIER, $token->jsonSerialize(), [], $lifetime);
}
private function getProvider(array $config): AbstractProvider private function getProvider(array $config): AbstractProvider
{ {
return new GenericProvider([ return new GenericProvider([
// The OAuth round trip goes through the provider's own Guzzle client, not the
// Symfony client configured in getHttpClient(), so it needs its own timeout -
// otherwise the token request hangs indefinitely and the data-request timeout
// never gets a chance to apply. AbstractProvider::getAllowedClientOptions()
// forwards 'timeout' (and 'proxy') into Guzzle; 0 means unlimited there too.
'timeout' => (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT),
'clientId' => $config['myEpApiClientId'], 'clientId' => $config['myEpApiClientId'],
'clientSecret' => $config['myEpApiClientSecret'], 'clientSecret' => $config['myEpApiClientSecret'],
'redirectUri' => null, 'redirectUri' => null,
@@ -1,233 +0,0 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Reads the product export from GET /api/exports/products on MyE&P. The rows
* already have the shape {@see DateImportService::import()} consumes, so they
* are passed on unchanged.
*
* Change detection compares GET /api/last-update against the
* `meta.sourceGeneratedAt` of the last successfully imported export, kept in
* var/transient/ep_products_import/api-imported.info. Both carry the BusPro
* export's own timestamp.
*/
final class ApiImportSource implements ProductImportSource, SingletonInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* The whole export is one page at current volumes; the walk does not rely on it.
*/
private const PER_PAGE = 500;
/**
* How often the walk restarts when MyE&P replaces its copy of the export
* mid-walk before giving up until the next run.
*/
private const MAX_WALKS = 3;
private const STAGING_SUBDIR = 'ep_products_import';
private const BASELINE_FILE = 'api-imported.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info';
/**
* @var ApiClient|null
*/
private $apiClient;
public function __construct(?ApiClient $apiClient = null)
{
$this->apiClient = $apiClient;
}
/**
* The whole export is fetched before anything is returned, so a walk that
* has to restart never leaves a stitched dataset behind.
*
* @throws ApiException
*/
public function acquire(): ?ProductImport
{
if (!$this->isNewImportAvailable()) {
return null;
}
for ($walk = 1; $walk <= self::MAX_WALKS; $walk++) {
$export = $this->walkPages();
if ($export === false) {
continue;
}
if ($export === null) {
return null;
}
[$rows, $sourceGeneratedAt] = $export;
$baseline = $this->getImportedTimestamp();
if ($baseline !== null && $sourceGeneratedAt <= $baseline) {
return null;
}
return new ProductImport($rows, $sourceGeneratedAt);
}
throw new ApiException(sprintf(
'MyE&P replaced its product export during each of %d page walks, giving up until the next run',
self::MAX_WALKS
));
}
public function markImported(ProductImport $import): void
{
$sourceGeneratedAt = $import->getSourceGeneratedAt();
if ($sourceGeneratedAt !== null) {
$file = $this->baselinePath();
GeneralUtility::mkdir_deep(dirname($file));
GeneralUtility::writeFile($file, $sourceGeneratedAt->format(\DateTimeInterface::ATOM), false);
}
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
/**
* True when MyE&P reports an export newer than the last imported one, or
* nothing has been imported yet. A missing timestamp from MyE&P means
* "not importable now", never "import".
*
* @throws ApiException
*/
public function isNewImportAvailable(): bool
{
$lastUpdateAt = $this->getApiClient()->getLastUpdateAt();
if ($lastUpdateAt === null) {
return false;
}
$baseline = $this->getImportedTimestamp();
return $baseline === null || $lastUpdateAt > $baseline;
}
/**
* `meta.sourceGeneratedAt` of the last successfully imported export.
*/
public function getImportedTimestamp(): ?\DateTimeImmutable
{
$file = $this->baselinePath();
if (!is_file($file)) {
return null;
}
return $this->parseTimestamp(trim((string) file_get_contents($file)));
}
/**
* @return array{0: array[], 1: \DateTimeImmutable}|false|null the rows and
* the export's timestamp; null when the export is not importable now;
* false when the export changed mid-walk and the walk must restart
* @throws ApiException
*/
private function walkPages()
{
$rows = [];
$sourceGeneratedAt = null;
$page = 1;
do {
$body = $this->getApiClient()->getProductExport($page, self::PER_PAGE);
$meta = $body['meta'] ?? null;
if (!is_array($meta) || !isset($body['data']) || !is_array($body['data'])) {
throw new ApiException(sprintf('MyE&P product export page %d is malformed', $page));
}
$pageGeneratedAt = $this->parseTimestamp($meta['sourceGeneratedAt'] ?? null);
if ($page === 1) {
if ($pageGeneratedAt === null) {
$this->logWarning('MyE&P product export has no source timestamp; skipping product import');
return null;
}
$sourceGeneratedAt = $pageGeneratedAt;
} elseif ($pageGeneratedAt === null || $pageGeneratedAt != $sourceGeneratedAt) {
$this->logWarning('MyE&P product export changed during the page walk; restarting', [
'page' => $page,
'expected' => $sourceGeneratedAt->format(\DateTimeInterface::ATOM),
'actual' => $meta['sourceGeneratedAt'] ?? null,
]);
return false;
}
foreach ($body['data'] as $row) {
$rows[] = $row;
}
$totalPages = (int) ($meta['totalPages'] ?? 0);
$page++;
} while ($page <= $totalPages);
$total = (int) ($meta['total'] ?? -1);
if (count($rows) !== $total) {
throw new ApiException(sprintf(
'MyE&P product export is incomplete: %d rows received, %d announced',
count($rows),
$total
));
}
if ((int) ($meta['skipped'] ?? 0) > 0) {
$this->logWarning('MyE&P skipped travel dates with unparseable dates', ['skipped' => $meta['skipped']]);
}
if ((int) ($meta['pickupsWithoutMasterData'] ?? 0) > 0) {
$this->logWarning(
'MyE&P product export has pickups without master data',
['pickupsWithoutMasterData' => $meta['pickupsWithoutMasterData']]
);
}
return [$rows, $sourceGeneratedAt];
}
/**
* @param mixed $value
*/
private function parseTimestamp($value): ?\DateTimeImmutable
{
if (!is_string($value) || $value === '') {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception $e) {
return null;
}
}
private function getApiClient(): ApiClient
{
if ($this->apiClient === null) {
$this->apiClient = GeneralUtility::makeInstance(ApiClient::class);
}
return $this->apiClient;
}
private function baselinePath(): string
{
return Environment::getVarPath() . '/transient/' . self::STAGING_SUBDIR . '/' . self::BASELINE_FILE;
}
private function logWarning(string $message, array $context = []): void
{
if ($this->logger !== null) {
$this->logger->warning($message, $context);
}
}
}
@@ -50,42 +50,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
const STATUS_ON_REQUEST = 'Anfrage'; const STATUS_ON_REQUEST = 'Anfrage';
const STATUS_STOP = 'Buchungsstop'; const STATUS_STOP = 'Buchungsstop';
/**
* BusPro ids of the selection groups whose selections make up the included
* services. Matched by id rather than by label, so a group renamed upstream
* does not silently drop out of services_included.
*/
const SELECTION_GROUP_SKIPASS = 7;
const SELECTION_GROUP_OTHER = 8;
const SELECTION_GROUP_BOARD = 71;
const SELECTION_GROUP_ACCOMMODATION = 85;
const SELECTION_GROUP_PROGRAMME = 86;
const SELECTION_GROUP_COURSES = 87;
const SELECTION_GROUP_EVENTS = 89;
const SELECTION_GROUP_TRANSPORTATION = 90;
/**
* Group 'Kunde' also carries trip types (Wochenreise, Midweekreise, ...), so
* group travel keys on the selection, not on the group's presence. Every
* date should carry either 'Gruppenkunde' or 'Endkunde'.
*/
const SELECTION_GROUP_CUSTOMER = 96;
const SELECTION_CUSTOMER_GROUP = 1418;
const SELECTION_CUSTOMER_END = 1419;
/**
* Fallback for dates the office has not tagged with either selection: these
* trip type markers in front of the travel code's trailing date mean
* end-customer travel.
*/
const END_CUSTOMER_CODE_PATTERN = '/(?:MP|WE|KT|PM|MW|FA)\d{6}$/';
/**
* 'Leistungen Personal' occurs in no export so far, so its id is unknown and
* it is still matched by label. Register its id once the warning fires.
*/
const SELECTION_GROUP_LABEL_PERSONNEL = 'Leistungen Personal';
/** /**
* @var ConfigurationManagerInterface * @var ConfigurationManagerInterface
*/ */
@@ -96,11 +60,21 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $pid = 0; protected $pid = 0;
/**
* @var array
*/
protected $roomMappings = [];
/** /**
* @var array * @var array
*/ */
protected $hotelMappings = []; protected $hotelMappings = [];
/**
* @var array
*/
protected $pickups = [];
/** /**
* @var Connection * @var Connection
*/ */
@@ -111,16 +85,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $cacheService; protected $cacheService;
/**
* @var ProductImportSourceFactory
*/
protected $importSourceFactory;
/**
* @var RoomTypeResolver
*/
protected $roomTypeResolver;
/** /**
* @var array * @var array
*/ */
@@ -131,19 +95,10 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param ConfigurationManagerInterface $configurationManager * @param ConfigurationManagerInterface $configurationManager
* @param CacheService $cacheService * @param CacheService $cacheService
* @param ProductImportSourceFactory $importSourceFactory
* @param RoomTypeResolver $roomTypeResolver
*/ */
public function __construct( public function __construct(ConfigurationManagerInterface $configurationManager, CacheService $cacheService) {
ConfigurationManagerInterface $configurationManager,
CacheService $cacheService,
ProductImportSourceFactory $importSourceFactory,
RoomTypeResolver $roomTypeResolver
) {
$this->configurationManager = $configurationManager; $this->configurationManager = $configurationManager;
$this->cacheService = $cacheService; $this->cacheService = $cacheService;
$this->importSourceFactory = $importSourceFactory;
$this->roomTypeResolver = $roomTypeResolver;
$settings = GeneralUtility::removeDotsFromTS( $settings = GeneralUtility::removeDotsFromTS(
$this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT) $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT)
@@ -157,18 +112,19 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param iterable<array> $rows one row per travel date × hotel, in the shape * @param string $path
* of GET /api/exports/products on MyE&P
* @return int * @return int
* @throws \Doctrine\DBAL\DBALException * @throws \Doctrine\DBAL\DBALException
*/ */
public function import(iterable $rows): int public function import($path): int
{ {
$this->db = $this->getDbConnection(); $this->db = $this->getDbConnection();
$this->getRoomMappings();
$this->getHotelMappings(); $this->getHotelMappings();
$this->importPickups();
$this->createTempTables(); $this->createTempTables();
$this->logger->info('starting product import'); $this->logger->info('starting product import');
$dateCount = $this->importProducts($rows); $dateCount = $this->parseXmlFilesForProducts($path);
$this->clearTables(); $this->clearTables();
$this->copyTables(); $this->copyTables();
$this->clearTempTables(); $this->clearTempTables();
@@ -179,47 +135,70 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
public function isImportRequired(): bool public function isImportRequired(): bool
{ {
return $this->importSourceFactory->get()->isNewImportAvailable(); $timestampService = new ImportTimestampService();
return $timestampService->isImportRequired(
'fileadmin/xmlexport/uebertragung.info',
'fileadmin/products_import.info'
);
} }
/** /**
* Groups the rows by product and travel date, in the order they first * @param $path
* appear, because a product code can map to more than one TYPO3 product and
* each of them imports all of the product's dates.
* *
* @param iterable<array> $rows * @return bool
* @return int
*/ */
protected function importProducts(iterable $rows): int public function checkActiveUpload($path): bool
{ {
$products = []; $tempFile = $path . '/.pureftpd-upload.*';
foreach ($rows as $row) {
$code = $row['product']['code'];
if (!isset($products[$code])) {
$products[$code] = ['busProId' => (int) $row['product']['busProId'], 'dates' => []];
}
$products[$code]['dates'][(int) $row['date']['busProId']][] = $row;
}
return count(glob($tempFile)) > 0;
}
public function parseXmlFilesForProducts($path): int
{
$dateCount = 0; $dateCount = 0;
foreach ($products as $code => $product) {
$dateCount += $this->importProduct((string) $code, $product['busProId'], $product['dates']); $folder = opendir($path);
while (($fileName = readdir($folder)) !== false) {
if (strpos($fileName, 'Ziel') === 0) {
$filePath = $path . '/' . $fileName;
$dateCount += $this->parseProductXmlFile($filePath);
}
} }
return $dateCount; return $dateCount;
} }
/** /**
* Recreated on every run, so the temp tables always follow the schema of the * @param string $file
* live tables (a column type change would otherwise survive in them) and a * @return int
* crashed import cannot leave rows behind for the next one.
*/ */
public function parseProductXmlFile($file): int
{
if (!file_exists($file)) {
$this->logger->error('product xml not found', ['file' => $file]);
return 0;
}
libxml_use_internal_errors(true);
/** @var \SimpleXMLElement $xmlData */
if (!$xmlData = simplexml_load_string(file_get_contents($file))) {
libxml_clear_errors();
return 0;
}
if ($xmlData->reise[0] === null) {
return 0;
}
return $this->parseProduct($xmlData->reise[0]);
}
public function createTempTables(): void public function createTempTables(): void
{ {
$this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_date_temp'); $this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date');
$this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_room_temp'); $this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room');
} }
public function clearTables(): void public function clearTables(): void
@@ -241,13 +220,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param string $code * @param \SimpleXMLElement $xmlProduct
* @param int $busProId
* @param array[][] $dates rows grouped by travel date
* @return int * @return int
*/ */
protected function importProduct(string $code, int $busProId, array $dates): int protected function parseProduct(\SimpleXMLElement $xmlProduct): int
{ {
$code = (string) $xmlProduct->attributes()['code'];
$busProId = (int) $xmlProduct->attributes()['idbuspro'];
$dateCount = 0; $dateCount = 0;
$sql = ' $sql = '
@@ -285,8 +264,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$product['bus_pro_id'] = $busProId; $product['bus_pro_id'] = $busProId;
$product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']); $product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']);
$product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']); $product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']);
foreach ($dates as $rows) { foreach ($xmlProduct->termin as $xmlDate) {
$dateCount += $this->importDate($rows, $product); $dateCount += $this->parseDates($xmlDate, $product);
} }
} }
@@ -294,21 +273,25 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param array[] $rows one per hotel of the travel date * @param \SimpleXMLElement $xmlDate
* @param array $product * @param array $product
* @return int * @return int
*/ */
protected function importDate(array $rows, array $product): int protected function parseDates(\SimpleXMLElement $xmlDate, array $product): int
{ {
$dateCount = 0; $dateCount = 0;
// $earliestDate = new \DateTimeImmutable('next year');
// Selection groups are date-level, so any hotel row tells. foreach ($xmlDate->hotel as $xmlHotel) {
$this->checkCustomerSelection(reset($rows)); // don't import dates starting later than one year from now
// $dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']);
// if ($dateStart > $earliestDate) {
// continue;
// }
foreach ($rows as $row) { $date = $this->createDateRecord($product, $xmlDate);
$date = $this->createDateRecord($product, $row);
$hotelCode = $row['hotel']['code']; $hotelCode = (string) $xmlHotel->attributes()['code'];
$key = $hotelCode . $date['date_start']; $key = $hotelCode . $date['date_start'];
// Import daytrip dates only once per hotel as room information is taken // Import daytrip dates only once per hotel as room information is taken
@@ -317,7 +300,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue; continue;
} }
if ($this->parseHotel($row, $date) !== false) { if ($this->parseHotel($xmlHotel, $date) !== false) {
$this->db->insert( $this->db->insert(
'tx_epproducts_domain_model_date_temp', 'tx_epproducts_domain_model_date_temp',
$date $date
@@ -343,17 +326,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param array $product * @param array $product
* @param array $row * @param \SimpleXMLElement $xmlDate
* @return array * @return array
*/ */
protected function createDateRecord(array $product, array $row): array protected function createDateRecord(array $product, \SimpleXMLElement $xmlDate): array
{ {
$dateStart = \DateTime::createFromFormat('Y-m-d', $row['date']['from']); $dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']);
$dateEnd = \DateTime::createFromFormat('Y-m-d', $row['date']['to']); $dateEnd = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['bis']);
$nights = $dateStart->diff($dateEnd)->format('%d'); $nights = $dateStart->diff($dateEnd)->format('%d');
$busProId = (int) $row['date']['busProId']; $busProId = (int) $xmlDate->attributes()['idbuspro'];
$code = (string) $row['date']['code']; $code = (string) $xmlDate->attributes()['code'];
$date = [ $date = [
'pid' => $this->pid, 'pid' => $this->pid,
@@ -396,15 +379,15 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
'important' => $product['important'], 'important' => $product['important'],
]; ];
$includedAccommodation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_ACCOMMODATION); $includedAccommodation = $this->parseSelectionGroup($xmlDate, 'Leistungen Übernachtung');
$includedSkiPass = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_SKIPASS); $includedSkiPass = $this->parseSelectionGroup($xmlDate, 'Leistungen Skipass');
$includedBoard = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_BOARD); $includedBoard = $this->parseSelectionGroup($xmlDate, 'Leistungen Verpflegung');
$includedTransportation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_TRANSPORTATION); $includedTransportation = $this->parseSelectionGroup($xmlDate, 'Leistungen Transport');
$includedProgramme = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_PROGRAMME); $includedProgramme = $this->parseSelectionGroup($xmlDate, 'Leistungen Programm');
$includedPersonnel = $this->parsePersonnelSelectionGroup($row['selectionGroups']); $includedPersonnel = $this->parseSelectionGroup($xmlDate, 'Leistungen Personal');
$includedEvents = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_EVENTS); $includedEvents = $this->parseSelectionGroup($xmlDate, 'Leistungen Events');
$includedOther = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_OTHER); $includedOther = $this->parseSelectionGroup($xmlDate, 'Leistungen Sonstige');
$includedCourses = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_COURSES); $includedCourses = $this->parseSelectionGroup($xmlDate, 'Leistungen Kurse');
$includedServices = [ $includedServices = [
...$includedAccommodation, ...$includedAccommodation,
@@ -429,9 +412,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$skiPassIncluded = (int) (count($includedSkiPass) > 0); $skiPassIncluded = (int) (count($includedSkiPass) > 0);
$date['skipass_included'] = $skiPassIncluded; $date['skipass_included'] = $skiPassIncluded;
$date['group_travel'] = (int) $this->isGroupTravel($row); $servicesTransportation = $this->parseService($xmlDate, 'lei_befoerderung');
$servicesTransportation = $this->parseService($row['transportServices']);
$discount = $this->determineDiscount($servicesTransportation); $discount = $this->determineDiscount($servicesTransportation);
$date['discount'] = $discount; $date['discount'] = $discount;
@@ -453,11 +434,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
if ($discount) { if ($discount) {
$servicesTransportationOptional[] = [ $servicesTransportationOptional[] = [
'label' => 'Rabatt bei Eigenanreise', 'label' => 'Rabatt bei Eigenanreise',
'price' => number_format($discount, 2, ',', '.'), 'price' => $discount,
]; ];
} }
$servicesGeneral = $this->parseService($row['dateServices']); $servicesGeneral = $this->parseService($xmlDate, 'lei_sonstiges');
$combinedServices = []; $combinedServices = [];
@@ -479,22 +460,29 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$date['services_general'] = serialize($combinedServices); $date['services_general'] = serialize($combinedServices);
$pickups = []; $pickups = [];
foreach ($row['pickups'] as $pickup) { foreach ($xmlDate->xpath('zustiege/zustieg') as $pickup) {
$busProId = (int) $pickup['busProId']; $attributes = $pickup->attributes();
if (null === $attributes) {
continue;
}
$busProId = (int) $attributes['idbuspro'];
if ($busProId === 0) { if ($busProId === 0) {
continue; continue;
} }
$pickupDate = $dateStart->format('d.m.Y'); $timestamp = (string) $attributes['zeit'];
$pickupTime = ''; $parts = explode(' ', $timestamp);
if ($pickup['datetime'] !== null) { if (2 === count($parts)) {
[$pickupDate, $pickupTime] = $this->parsePickupDateTime($pickup['datetime']); [$pickupDate, $timePart] = $parts;
$pickupTime = substr($timePart, 0, 5);
} else {
$pickupDate = $dateStart->format('d.m.Y');
$pickupTime = $parts[0];
} }
$pickups[] = [ $pickups[] = [
'bus_pro_id' => $busProId, 'bus_pro_id' => $busProId,
'city' => $pickup['city'], 'city' => $this->pickups[$busProId]['city'],
'street' => $pickup['street'], 'street' => $this->pickups[$busProId]['street'],
// A pickup without a stated price has always been shown as included. 'price' => (float) str_replace(',', '.', (string) $attributes['preis']),
'price' => $pickup['price'] === null ? 0.0 : (float) $pickup['price'],
'date' => $pickupDate, 'date' => $pickupDate,
'time' => $pickupTime, 'time' => $pickupTime,
]; ];
@@ -511,20 +499,21 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}); });
$date['pickups'] = serialize($pickups); $date['pickups'] = serialize($pickups);
$date['guide'] = serialize($row['guide'] ?? []); $guide = $this->parseGuide($xmlDate);
$date['guide'] = serialize($guide);
return $date; return $date;
} }
/** /**
* @param array $row * @param \SimpleXMLElement $xmlHotel
* @param array $date * @param array $date
* @return bool * @return bool
*/ */
protected function parseHotel(array $row, array &$date): bool protected function parseHotel(\SimpleXMLElement $xmlHotel, array &$date): bool
{ {
$code = $row['hotel']['code']; $code = (string) $xmlHotel->attributes()['code'];
$busProId = (int) $row['hotel']['busProId']; $busProId = (string) $xmlHotel->attributes()['idbuspro'];
if (!array_key_exists($code, $this->hotelMappings)) { if (!array_key_exists($code, $this->hotelMappings)) {
$this->logger->warning('hotel not found', ['code' => $code]); $this->logger->warning('hotel not found', ['code' => $code]);
@@ -537,7 +526,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$services = []; $services = [];
$generalServices = unserialize($date['services_general']); $generalServices = unserialize($date['services_general']);
$hotelServices = $this->parseService($row['hotelServices'], true); $hotelServices = $this->parseService($xmlHotel, 'lei_sonstiges', true);
$this->addServices($generalServices, $services); $this->addServices($generalServices, $services);
$this->addServices($hotelServices, $services); $this->addServices($hotelServices, $services);
foreach ($services as $key => $list) { foreach ($services as $key => $list) {
@@ -547,7 +536,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$services[$key] = $list; $services[$key] = $list;
} }
$this->parseRooms($row, $hotel['uid'], $date, $services); $this->parseRooms($xmlHotel, $hotel['uid'], $date, $services);
$date['hotel'] = $hotel['uid']; $date['hotel'] = $hotel['uid'];
$date['hotel_code'] = $hotel['code']; $date['hotel_code'] = $hotel['code'];
@@ -566,91 +555,108 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return true; return true;
} }
protected function parseGuide(\SimpleXMLElement $xmlDate)
{
$transportationServicesBus = $xmlDate->xpath('lei_befoerderung/leistung[@unterart="BUS"]');
foreach ($transportationServicesBus as $service) {
if ('RUECK' === (string) $service->richtung) {
continue;
}
if (false === isset($service->zustiegsplanung->reiseleiter)) {
continue;
}
return [
'name' => (string) $service->zustiegsplanung->reiseleiter->name,
'phone' => (string) $service->zustiegsplanung->reiseleiter->telefon,
];
}
return [];
}
/** /**
* @param array $row * @param \SimpleXMLElement $xmlHotel
* @param int $hotelUid * @param int $hotelUid
* @param array $date * @param array $date
* @param array $services * @param array $services
*/ */
protected function parseRooms(array $row, $hotelUid, array &$date, array $services): void protected function parseRooms(\SimpleXMLElement $xmlHotel, $hotelUid, array &$date, array $services): void
{ {
$minPriceOverall = 0.0; $minPriceOverall = 0;
$minPriceAvailable = 0.0; $minPriceAvailable = 0;
$minPax = 0; $minPax = 0;
$availableTotal = 0; $availableTotal = 0;
$boardCode = ''; $boardCode = '';
$count = 0; $count = 0;
if ($row['pseudoPrice'] !== null) { foreach ($xmlHotel->xpath('zimmer/preis') as $roomXml) {
$date['pseudo_price'] = (float) $row['pseudoPrice']; $code = trim((string) $roomXml->attributes()['zimmercode']);
}
foreach ($row['rooms'] as $roomRow) { if ($code === static::CODE_PSEUDOPRICE) {
$code = $roomRow['code']; $price = (int) $roomXml->attributes()['preis'];
$label = $roomRow['label']; $date['pseudo_price'] = $price;
$roomMinPax = (int) $roomRow['minPax']; continue;
$pax = (int) $roomRow['maxPax']; }
$available = (int) $roomRow['available']; $roomTypeKey = mb_strtolower($code);
$price = (float) $roomRow['price']; if (array_key_exists($roomTypeKey, $this->roomMappings)) {
$roomType = $this->roomMappings[$roomTypeKey];
} else {
$this->logger->warning('room type not found', ['code' => $code]);
continue;
}
$available = (int) $roomXml->attributes()['verfuegbar'];
$price = (int) $roomXml->attributes()['preis'];
// Skip rooms with zero price _and_ zero availability. // Skip rooms with zero price _and_ zero availability.
if (!$available && !$price) { if (!$available && !$price) {
continue; continue;
} }
$roomType = $this->roomTypeResolver->resolve($code, $label, $pax, $roomMinPax); $pax = (int) $roomXml->attributes()['MinPax'];
if (0 === $minPax || $pax < $minPax) {
if ($pax < 1 && $roomMinPax < 1) { $minPax = $pax;
$this->logger->warning('room without occupancy', ['code' => $code, 'zimmertext' => $label]);
}
if ($this->roomTypeResolver->needsReview($code, $label, $pax)) {
$this->logger->warning(
'single-occupancy room without single-use marker',
['code' => $code, 'zimmertext' => $label]
);
}
if (0 === $minPax || $roomMinPax < $minPax) {
$minPax = $roomMinPax;
} }
$pax = (int) $roomXml->attributes()['MaxPax'];
$availableTotal += $available * $pax; $availableTotal += $available * $pax;
// Determine lowest price of available rooms // Determine lowest price of available rooms
if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0.0 || $price < $minPriceAvailable)) { if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0 || $price < $minPriceAvailable)) {
$minPriceAvailable = $price; $minPriceAvailable = $price;
} }
// Determine lowest overall price // Determine lowest overall price
if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0.0 || $price < $minPriceOverall)) { if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0 || $price < $minPriceOverall)) {
$minPriceOverall = $price; $minPriceOverall = $price;
} }
// Determine status // Determine status
$sourceStatus = $roomRow['status']; $statusXml = (string) $roomXml->attributes()['status'];
if (static::STATUS_STOP === $sourceStatus && $price) { if (static::STATUS_STOP === $statusXml && $price) {
$status = Room::ROOM_STATUS_FULLY_BOOKED; $status = Room::ROOM_STATUS_FULLY_BOOKED;
} elseif (static::STATUS_ON_REQUEST === $sourceStatus) { } elseif (static::STATUS_ON_REQUEST === $statusXml) {
$status = Room::ROOM_STATUS_ON_REQUEST; $status = Room::ROOM_STATUS_ON_REQUEST;
} else { } else {
$status = Room::ROOM_STATUS_AVAILABLE; $status = Room::ROOM_STATUS_AVAILABLE;
} }
$boardCode = $roomRow['boardCode']; $boardCode = (string) $roomXml->attributes()['vpcode'];
$room = [ $room = [
'pid' => $this->pid, 'pid' => $this->pid,
'date' => 0, 'date' => 0,
'hotel' => $hotelUid, 'hotel' => $hotelUid,
'bus_pro_id' => (int) $roomRow['busProId'], 'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'],
'code' => $code, 'code' => $code,
'type' => $roomType, 'type' => $roomType,
'name' => $label, 'name' => (string) $roomXml->attributes()['zimmertext'],
'pax' => $pax, 'pax' => $pax,
'nights' => (int) $roomRow['nights'], 'nights' => (int) $roomXml->attributes()['naechte'],
'price' => $price, 'price' => $price,
'available' => $available * $pax, 'available' => $available * $pax,
'status' => $status, 'status' => $status,
@@ -674,43 +680,45 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param array $services * @param array $services
* @return float * @return int
*/ */
protected function determineDiscount(array $services): float protected function determineDiscount(array $services): int
{ {
foreach ($services as $key => $section) { foreach ($services as $key => $section) {
foreach ($section as $service) { foreach ($section as $service) {
$price = $this->parseFormattedPrice($service['price']); if ($service['price'] < 0) {
if ($price < 0) { return (int) $service['price'];
return $price;
} }
} }
} }
return 0.0; return 0;
} }
/** /**
* @param array[] $services a service list of a row * @param \SimpleXMLElement $xmlData
* @param string $serviceLabel
* @param bool $skipWithoutPrice * @param bool $skipWithoutPrice
* @return array * @return array
*/ */
protected function parseService(array $services, bool $skipWithoutPrice = false): array protected function parseService(\SimpleXMLElement $xmlData, string $serviceLabel, bool $skipWithoutPrice = false): array
{ {
$data = []; $data = [];
$services = $xmlData->xpath($serviceLabel . '/leistung');
foreach ($services as $service) { foreach ($services as $service) {
// Skip transportation service direction 'back' // Skip transportation service direction 'back'
if ($service['direction'] === 'RUECK') { if ($service->richtung && (string) $service->richtung === 'RUECK') {
continue; continue;
} }
// Skip services with status 'Buchungsstop' // Skip services with status 'Buchungsstop'
if ($service['status'] === static::STATUS_STOP) { $status = (string) $service->status;
if ($status === 'Buchungsstop') {
continue; continue;
} }
// Skip services of subcategory 'EIN' // Skip services of subcategory 'EIN'
$type = $service['subType']; $type = (string) $service->attributes()['unterart'];
if ($type === 'EIN') { if ($type === 'EIN') {
continue; continue;
} }
@@ -720,14 +728,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$type = 'VER'; $type = 'VER';
} }
// A service without a stated price has always been shown as included. $price = (float) str_replace(',', '.', $service->preis);
$price = $service['price'] === null ? 0.0 : (float) $service['price'];
if ($skipWithoutPrice && $price === 0.0) { if ($skipWithoutPrice && $price === 0.0) {
continue; continue;
} }
$label = $service['label']; $label = (string) $service->text;
if ($type === 'BUS') { if ($type === 'BUS') {
$label = static::LABEL_BUS; $label = static::LABEL_BUS;
} elseif ($type === 'BAH') { } elseif ($type === 'BAH') {
@@ -738,8 +745,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$data[$type] = []; $data[$type] = [];
} }
$data[$type][(int) $service['busProId']] = [ $data[$type][(int)$service->attributes()['id']] = [
'idBusPro' => (int) $service['busProId'], 'idBusPro' => (int) $service->attributes()['idbuspro'],
'label' => $label, 'label' => $label,
'price' => number_format($price, 2, ',', '.'), 'price' => number_format($price, 2, ',', '.'),
]; ];
@@ -751,40 +758,21 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param array[] $selectionGroups * @param \SimpleXMLElement $xmlDate
* @param int $busProId * @param string $label
* @return array * @return array
*/ */
protected function parseSelectionGroup(array $selectionGroups, int $busProId): array protected function parseSelectionGroup(\SimpleXMLElement $xmlDate, string $label): array
{ {
$data = []; $data = [];
foreach ($selectionGroups as $group) { $selectionGroup = $xmlDate->xpath('selektiongruppe[@bezeichnung="' . $label . '"]/selektion');
if ((int) $group['busProId'] === $busProId) { foreach ($selectionGroup as $selection) {
$this->addSelections($group['selections'], $data); $attributes = $selection->attributes();
} if (null === $attributes) {
}
ksort($data);
return $data;
}
/**
* @param array[] $selectionGroups
* @return array
*/
protected function parsePersonnelSelectionGroup(array $selectionGroups): array
{
$data = [];
foreach ($selectionGroups as $group) {
if ($group['label'] !== static::SELECTION_GROUP_LABEL_PERSONNEL) {
continue; continue;
} }
$this->logger->warning( $id = (int) $attributes['idbuspro'];
'selection group matched by label, register its id', $data[$id] = (string) $attributes['bezeichnung'];
['label' => $group['label'], 'idbuspro' => (int) $group['busProId']]
);
$this->addSelections($group['selections'], $data);
} }
ksort($data); ksort($data);
@@ -792,60 +780,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return $data; return $data;
} }
/**
* 'Gruppenkunde' and 'Endkunde' decide. A date carrying neither is judged by
* its travel code.
*
* @param array $row
* @return bool
*/
protected function isGroupTravel(array $row): bool
{
$customer = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_CUSTOMER);
$isGroup = isset($customer[static::SELECTION_CUSTOMER_GROUP]);
$isEnd = isset($customer[static::SELECTION_CUSTOMER_END]);
if ($isGroup || $isEnd) {
return $isGroup;
}
return !preg_match(static::END_CUSTOMER_CODE_PATTERN, (string) $row['date']['code']);
}
/**
* Reports dates whose customer selection is missing or contradictory, along
* with what they import as.
*
* @param array $row
*/
protected function checkCustomerSelection(array $row): void
{
$customer = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_CUSTOMER);
$isGroup = isset($customer[static::SELECTION_CUSTOMER_GROUP]);
$isEnd = isset($customer[static::SELECTION_CUSTOMER_END]);
if ($isGroup !== $isEnd) {
return;
}
$this->logger->warning(
$isGroup
? 'date has both Gruppenkunde and Endkunde selected, importing as group travel'
: 'date has neither Gruppenkunde nor Endkunde selected, falling back to travel code, importing as '
. ($this->isGroupTravel($row) ? 'group travel' : 'end-customer travel'),
['product' => $row['product']['code'], 'date' => $row['date']['code']]
);
}
/**
* @param array[] $selections
* @param array $data
*/
protected function addSelections(array $selections, array &$data): void
{
foreach ($selections as $selection) {
$data[(int) $selection['busProId']] = $selection['label'];
}
}
/** /**
* @param array $services * @param array $services
* @param array $date * @param array $date
@@ -857,42 +791,20 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
foreach ($services['BUS'] as $service) { foreach ($services['BUS'] as $service) {
$price = $this->parseFormattedPrice($service['price']);
$date['bus_available'] = 1; $date['bus_available'] = 1;
$date['bus_price'] = $price; $date['bus_price'] = (int) $service['price'];
$date['bus_included'] = (int) ($price === 0.0); $date['bus_included'] = (int) ((int) $service['price'] === 0);
} }
} }
/** protected function getRoomMappings(): void
* A pickup time is the local date and time as BusPro states it
* ("2027-03-28T02:45:00"), taken over as is. An ISO-8601 time with an
* offset is still accepted and converted to Europe/Berlin, which cannot
* express a time inside the daylight saving gap.
*
* @param string $datetime
* @return string[] date as d.m.Y and time as H:i
*/
protected function parsePickupDateTime(string $datetime): array
{ {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/', $datetime, $parts)) { $sql = 'SELECT code, type FROM tx_epproducts_domain_model_roommapping WHERE deleted = 0 AND hidden = 0';
return [$parts[3] . '.' . $parts[2] . '.' . $parts[1], $parts[4] . ':' . $parts[5]]; $roomMappings = $this->db->fetchAllAssociative($sql);
foreach ($roomMappings as $mapping) {
$key = trim(mb_strtolower($mapping['code']));
$this->roomMappings[$key] = $mapping['type'];
} }
$pickupDateTime = (new \DateTimeImmutable($datetime))->setTimezone(new \DateTimeZone('Europe/Berlin'));
return [$pickupDateTime->format('d.m.Y'), $pickupDateTime->format('H:i')];
}
/**
* Reads back a price parseService() formatted for display ("1.234,50").
*
* @param string $price
* @return float
*/
protected function parseFormattedPrice(string $price): float
{
return (float) str_replace(['.', ','], ['', '.'], $price);
} }
protected function getHotelMappings(): void protected function getHotelMappings(): void
@@ -908,9 +820,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0 WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0
UNION SELECT matchcode.code code, matchcode.entity uid UNION SELECT matchcode.code code, matchcode.entity uid
FROM tx_epproducts_domain_model_matchcode matchcode FROM tx_epproducts_domain_model_matchcode matchcode
INNER JOIN tx_epproducts_domain_model_hotel matchcode_hotel ON matchcode.entity = matchcode_hotel.uid
WHERE matchcode.deleted = 0 AND matchcode.hidden = 0 WHERE matchcode.deleted = 0 AND matchcode.hidden = 0
AND matchcode_hotel.deleted = 0 AND matchcode_hotel.hidden = 0
ORDER BY code ORDER BY code
) u ) u
LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid
@@ -967,4 +877,26 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
} }
protected function importPickups(): void
{
libxml_use_internal_errors (true);
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport/zustiege.xml');
if (!$xmlData = simplexml_load_string(file_get_contents($path))) {
libxml_clear_errors();
return;
}
foreach ($xmlData->zustieg as $entry) {
$attributes = $entry->attributes();
if (null === $attributes) {
continue;
}
$idBusPro = (int) $attributes['idbuspro'];
$this->pickups[$idBusPro] = [
'city' => (string) $entry->ort,
'street' => (string) $entry->strasse,
];
}
}
} }
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more