Das Enneo SDK bietet eine umfassende Sammlung von Werkzeugen, die die Entwicklung von spezifischem Code für kundenspezifische Lösungen im Bereich des KI-gestützten Kundensupports erleichtert. Das SDK unterstützt die Implementierung regelbasierter Logik für KI-Agenten, eventbasierte Integrationen, kundenspezifische Webhooks und benutzerdefinierte Funktionen. Das SDK wird automatisch geladen und die Funktionen stehen direkt zur Verfügung.

Einbindung des SDK im Code

Um das Enneo SDK in Ihren Code einzubinden, kann folgendes Code-Snippet verwendet werden:

<?php

require(getenv()['SDK'] ?? 'sdk.php');

Enneo API Wrapper

Das Enneo SDK bietet Wrapper für die Enneo API Endpunkte, mit denen bequem auf die API zugegriffen werden kann:

  • Laden der Vertragsdaten anhand der Vertrags-ID

  • Laden der Ticket-Daten anhand der Ticket-ID

  • Ausführen benutzerdefinierter Funktionen

  • Aufrufen beliebiger Enneo-API Endpunkte (GET | POST | PATCH | PUT | DELETE)

    <?php
    
    use EnneoSDK\ApiEnneo;
    
    require(getenv()['SDK'] ?? 'sdk.php');
    
    /**
     * Lädt die Vertragsdaten zum Vertrag mit der ID 123
     */
    $contract = ApiEnneo::getContract(contractId: 123);
    
    /**
     * Lädt Ticketdaten zum Ticket mit der ID 456
     */
    $ticket = ApiEnneo::getTicket(ticketId: 456);
    
    /**
     * Fürt benutzerdefinierte Funktion 'user-defined-function' aus.
     * (s. Dokumentation der benutzerdefinierten Funktionen)
     * Eingegebene Parameter werden an die benutzerdefinierte Funktion übergeben
     * und können dort mittels \EnneoSDK\Input::load() geladen werden.
     */
    $response = ApiEnneo::executeUdf(
        name: 'user-defined-function',
        parameters: [
            'method' => 'POST',
            'api' => 'redirect',
            'params' => $payload
        ]
    );
    
    
    ##########################################################################
    ## Die Methoden get | post | patch | put | delete können verwendet werden,
    ## um beliebige Enneo API Endpunkte aufzurufen
    ## (s. API Dokumentation)
    ##########################################################################
    
    /**
     * Profil des aktuell eingeloggten Nutzers laden
     */
    $profile = ApiEnneo::get(endpoint: '/api/mind/profile');
    
    /**
     * OCR Zählerstandserkennung für vorgegebene Ticket-ID und URL zum Ticket-Anhang
     */
    $response = ApiEnneo::post(
        endpoint: '/api/cortex/ocrMeter',
        body: [
            'ticketId' => $ticketId,
            'fileUrl' => $_ENV['ENNEO_API_URL'] . $attachment->url,
        ]
    );
    
    /**
     * Ticket schließen
     */
    $response = ApiEnneo::patch(
        endpoint: '/api/mind/ticket/' . $ticketId,
        body: ['status' => 'closed']
    );
    

Aufruf benutzerdefinierter Funktionen

Der Aufruf benutzerdefinierter Funktionen im Code erfolgt mithilfe von executeUdf:

<?php

// Enneo SDK laden
require(getenv()['SDK']);

// Payload für den API Aufruf des Third-Party-Systems vorbereiten
$payload = [
    'ticket-id' => 123,
    'key' => 'value'
];

// Aufruf der benutzerdefinierten Funktion "third-party-api-call"
// diese Funktion erhält als Eingabeparameter ein Objekt:
// [
//      'method' => 'POST',
//      'api' => 'item',
//      'params' => [
//          'ticket-id' => 123,
//           'key' => 'value'
//      ]
// ]
EnneoSDK\ApiEnneo::executeUdf(
    'third-party-api-call',
    ['method' => 'POST', 'api' => 'item', 'params' => $payload]
);

SDK Code

<?php

// ----------------------
// ----- Enneo SDK ------
// ----------------------

namespace EnneoSDK;

use DateTime;
use Exception;
use stdClass;

define("ENNEO_SDK_VERSION", "0.2.3");

// Parametrisation (can be overriden by client)
$SDK_RETRY_ON_RATE_LIMIT = false;
$SDK_API_TIMEOUT = 60;


/**
* This is the input data that is passed to the business logic code.
* Added for convenience here.
*/
$_ENV = getenv();

// Aliases for input data functions
$in = Input::load(true);

class Input
{
private static object $in; // Cache input parameters to allow multiple calls to load()

/**
 * Loads the input parameters for the AI function that enneo was able to extract from the ticket/contract
 *
 * This function first checks if the input parameters are passed through STDIN.
 * If not, it checks if the input parameters are passed through a 'input.json' file in the working directory.
 * If the input parameters are passed through STDIN or the 'input.json' file, it returns the loaded input data as a dictionary.
 * The input.json file can be overriden with the environment variable INPUT_JSON
 *
 * @return object The input parameters extracted from Enneo from the ticket as key-value object
 */
public static function load(bool $includeMetadata = false): object
{
    if (isset(self::$in)) {
        return self::$in;
    }

    $inputStr = null;

    # Check if we have input from STDIN
    $stdin = fopen('php://stdin', 'r');
    stream_set_blocking($stdin, 0);
    $inputStr = stream_get_contents($stdin);
    fclose($stdin);

    # If not, check if we have input from a file
    if (!$inputStr && file_exists($_ENV['INPUT_JSON'] ?? 'input.json')) {
        $inputStr = file_get_contents($_ENV['INPUT_JSON'] ?? 'input.json');
    } elseif (!$inputStr) {
        echo "No input data found. Please provide input data as input.json or through stdin.\n";
        exit(1);
    }

    # Decode the input data from JSON to a PHP object
    self::$in = json_decode($inputStr);
    if (json_last_error() !== JSON_ERROR_NONE) {
        echo "Input parameters were not correctly loaded: " . json_last_error_msg() . "\n";
        echo "Your input was: " . $inputStr . "\n";
        exit(1);
    }

    # Some input values might be strings that are actually JSON objects
    # We try to decode them here
    foreach (self::$in as $key => $value) {
        if (is_string($value) && !is_numeric($value)) {
            $decoded = json_decode($value);
            if (json_last_error() === JSON_ERROR_NONE) {
                self::$in->$key = $decoded;
            }
        }
    }

    # Strip the metadata
    if (!$includeMetadata) {
        unset(self::$in->_metadata);
    }

    # Return the input data
    return self::$in;
}
}


/**
* Creates a new intent info (=message box visible to the agent in the Enneo UI).
*/
class IntentInfo
{
/**
 * @param string      $type      The type of the intent. Must be one of 'success', 'neutral', 'warning', 'danger'.
 * @param string      $message   The message to be shown to the agent, e.g. "Reading is plausible".
 * @param string|null $extraInfo Optional supplemental information about the intent, e.g. "Expected reading was 421 kWh. Plausbible because difference to 317 kWh is below threshold of 200 kWh"
 * @param string|null $code      Optional internal code associated with the info. Not used by enneo. Defaults to None.
 */
public function __construct(
    public string $type,
    public string $message,
    public ?string $extraInfo = null,
    public ?string $code = null,
) {
}
}

/**
* Creates a new intent option (=button visible to the agent in the Enneo UI).
*/
class IntentOption
{
/**
 * @param string $type The type of the intent. Must be unique for each option. Usually accompanied by a matching output case
 * @param string $name The name of the option, e.g. "Enter into system".
 * @param string $icon The icon to be shown next to the option. Defaults to 'check'. Options: check, cancel
 * @param bool $recommended True for default action. An Intent can only have one default action. Defaults to False.
 * @param int $order Sorting index order. Frontend sorts in ascending order
 * @param string|null $handler Which microservice should handle this action, Options: cortex, mind or fe. ONLY USE IF YOU KNOW WHAT YOU ARE DOING. Defaults to None.
 */
public function __construct(
    public string $type,
    public string $name,
    public string $icon = 'check',
    public bool $recommended = false,
    public int $order = 0,
    public string|null $handler = '',
) {
}
}

/**
* Represents a field in form, displayed in user UI
*/
class FormField
{
public function __construct(
    public string $id,
    public string $type,
    public string $valueRef,
    public string $label,
    public bool $readonly = false,
    public bool $hidden = false,
    public array $options = [],
) {
}
}

/**
* Represents a form (as a set of form fields), displayed in user UI
*/
class Form
{
public array $fields = [];

public static function fromInput(stdClass $in): Form
{
    $result = new Form();
    foreach ($in->_metadata->inputParameters as $param) {
        $field = new FormField(
            id: $param->key,
            type: match ($param->type) {
                'date',
                'datetime' => 'date',
                'int' => 'integer',
                'float' => 'number',
                'bool' => 'checkbox',
                'enum' => 'select',
                default => 'text',
            },
            valueRef: 'data.' . $param->key,
            label: $param->description,
            readonly: $param->visibility === 'readonly',
            hidden: $param->visibility === 'hidden',
            options: []
        );
        $optionsCounter = 1;
        foreach ($param->options as $option) {
            $field->options[] = [
                'label' => $option->label,
                'value' => $option->value,
                'id' => 'option_' . $optionsCounter++,
            ];
        }
        $result->fields[] = $field;
    }

    return $result;
}

public function getFormField(string $id): ?FormField
{
    foreach ($this->fields as $field) {
        if ($field->id === $id) {
            return $field;
        }
    }

    return null;
}
}

/**
* Creates a new interaction object.
* This is an enneo-specific object that defines the form shown to the agent in the Enneo UI.
* The standard AI function returns an json-encoded Interaction object to STDOUT
*/
class Interaction
{
/**
 * @param object $data      The input data for the AI function, i.e. form values for the input variables. It is recommended to pre-fill this with the input data coming from load_input_data().
 * @param array  $options   A list of intent options (=buttons visible to the agent in the Enneo UI). Defaults to [].
 * @param array  $infos     A list of intent infos (=message boxes visible to the agent in the Enneo UI). Defaults to [].
 * @param object|null $form Optional dictionary to override the forms to be shown to the agent. Usually not needed. Defaults to None.
 */
public function __construct(
    public object $data,
    public array $options = [],
    public array $infos = [],
    public ?object $form = null,
) {
    unset($this->data->_metadata);
}
}

/**
* These are wrappers for the Enneo API endpoints.
* You can use them to conveniently access the Enneo API.
*/
class ApiEnneo
{
/**
 * Get a contract by its ID.
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param int|string $contractId The contract ID
 * @return object The contract as object
 * @throws Exception
 */
public static function getContract(int|string $contractId): object
{
    return self::get('/api/mind/contract/' . $contractId . '?includeRawData=true');
}

/**
 * Get a ticket by its ID.
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param int $ticketId The ticket ID
 * @return object The ticket as object
 */
public static function getTicket(int $ticketId): object
{
    return self::get('/api/mind/ticket/' . $ticketId, authorizeAs: 'serviceWorker');
}

/**
 * Do an API GET call to an Enneo API endpoint
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param string $endpoint The endpoint, e.g. /api/mind/contract/123456
 * @param array $params The HTTP GET parameters, e.g. ['contractId' => 123456]. Can also be passed through the URL, e.g. /api/mind/ticket/123?includeIntents=true
 * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'.
 *
 * @return object|array The response body as object. All Enneo APIs return an object as response.
 * @throws Exception
 */
public static function get(string $endpoint, array $params = [], string $authorizeAs = 'user'): object|array
{
    $headers = self::buildAuthorizationHeader($authorizeAs);
    return Api::call("GET", self::getEnneoUrl($endpoint), $headers);
}

/**
 * Do an API POST call to an Enneo API endpoint
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param string $endpoint The endpoint, e.g. /api/mind/ticket
 * @param object|array $body The HTTP body payload as object or array. Will be JSON-encoded before sending.
 * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'.     *
 * @return object|array The response body as object
 * @throws Exception
 */
public static function post(string $endpoint, object|array $body, string $authorizeAs = 'user'): object|array
{
    $headers = self::buildAuthorizationHeader($authorizeAs);
    return Api::call("POST", self::getEnneoUrl($endpoint), $headers, $body);
}

/**
 * Do an API PATCH call to an Enneo API endpoint
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param string $endpoint The endpoint, e.g. /api/mind/ticket
 * @param object|array $body The HTTP body payload as object or array. Will be JSON-encoded before sending.
 * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'.
 *
 * @return object|array The response body as object
 * @throws Exception
 */
public static function patch(string $endpoint, object|array $body, string $authorizeAs = 'user'): object|array
{
    $headers = self::buildAuthorizationHeader($authorizeAs);
    return Api::call("PATCH", self::getEnneoUrl($endpoint), $headers, $body);
}

/**
 * Do an API PUT call to an Enneo API endpoint
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param string $endpoint The endpoint, e.g. /api/mind/ticket
 * @param object|array $body The HTTP body payload as object or array. Will be JSON-encoded before sending.
 * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'.
 *
 * @return object|array The response body as object
 * @throws Exception
 */
public static function put(string $endpoint, object|array $body, string $authorizeAs = 'user'): object|array
{
    $headers = self::buildAuthorizationHeader($authorizeAs);
    return Api::call("PUT", self::getEnneoUrl($endpoint), $headers, $body);
}

/**
 * Do an API DELETE call to an Enneo API endpoint
 * See https://main.enneo.dev/api/mind/docs for documentation.
 *
 * @param string $endpoint The endpoint, e.g. /api/mind/ticket
 * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'.
 *
 * @return object|array The response body as object
 * @throws Exception
 */
public static function delete(string $endpoint, string $authorizeAs = 'user'): object|array
{
    $headers = self::buildAuthorizationHeader($authorizeAs);
    return Api::call("DELETE", self::getEnneoUrl($endpoint), $headers);
}

/**
 * Execute a helper function.
 * See https://main.enneo.dev/api/mind/docs for documentation.
 * @param string $name The name of the helper function, e.g. 'getContract'
 * @param object|array $parameters The parameters to pass to the user defined function
 * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'.
 *
 * @return object|array
 * @throws Exception
 */
public static function executeUdf(string $name, object|array $parameters, string $authorizeAs = 'user'): object|array
{
    return ApiEnneo::post("/api/mind/executor/execute/$name", ["parameters" => $parameters], $authorizeAs);
}

/**
 * Internal helper function to build the authorization header.
 */
private static function buildAuthorizationHeader(string $authorizeAs): array
{
    if ($authorizeAs == 'user' && ($_ENV['ENNEO_USER_AUTH_HEADER'] ?? false)) {
        return [$_ENV['ENNEO_USER_AUTH_HEADER']];
    } else {
        return ['Authorization: Bearer ' . $_ENV['ENNEO_SESSION_TOKEN']];
    }
}

/**
 * Ensure we address the corect enneo microservice port when running inside Kubernetes
 */
private static function getEnneoUrl(string $endpoint): string
{
    if (str_starts_with($endpoint, '/api/mind')) {
        $enneoUrl = $_ENV['ENNEO_API_URL'];
    } elseif (str_starts_with($endpoint, '/api/cortex')) {
        $enneoUrl = str_replace(':8005', ':8006', $_ENV['ENNEO_API_URL']);
    } elseif (str_starts_with($endpoint, '/api/auth')) {
        $enneoUrl = str_replace(':8005', ':8002', $_ENV['ENNEO_API_URL']);
    } else {
        throw new Exception("Unknown Enneo API endpoint $endpoint. They should start with /api/mind or /api/cortex or /api/auth");
    }

    return $enneoUrl . $endpoint;
}
}

class Api
{
// -------------------------------------
// --- Shared REST service endpoints ---
// -------------------------------------

/**
 * HTTP call to REST-endpoint
 *
 * @param string $method  HTTP method: GET, POST, PUT, DELETE or PATCH
 * @param string $url   URL to call, e.g. https://my-api.com/api/v1/endpoint
 * @param array $headers Array of HTTP headers, e.g. ['Content-Type: application/json']
 * @param array|object|string|bool $params Body of the HTTP request, e.g. ['param1' => 'value1', 'param2' => 'value2']
 * @return array|object The response body as object
 * @throws Exception If the call was not successful, an exception is thrown
 */
public static function call(string $method, string $url, array $headers = [], array|object|string|false $params = false): array|object
{
    $curl = curl_init($url);

    // Log::info( "API $method call to $url about to start");
    if ($method == 'GET') {
        // No curl parameters necessary
    } elseif ($method == 'POST') {
        curl_setopt($curl, CURLOPT_POST, 1);
    } elseif ($method == 'PUT' || $method == 'DELETE' || $method == 'PATCH') {
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
    } else {
        throw new Exception("Invalid Curl request method $method");
    }

    $payload = null;

    if ($params && $params !== 'false' && $params !== '0') {
        if (is_object($params) || is_array($params)) {
            $payload = json_encode($params);
        } else {
            $payload = $params;
        }
        curl_setopt($curl, CURLOPT_POSTFIELDS, $payload);
    }

    // Set curl timeout to 10 seconds
    curl_setopt($curl, CURLOPT_TIMEOUT, $GLOBALS['SDK_API_TIMEOUT']);
    curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($curl, CURLOPT_HEADER, 1);
    $headers[] = 'Content-Type: application/json';
    $headers[] = 'User-Agent: enneo/1.0.0';
    curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); // Needed to avoid buggy powercloud SSL certificate
    curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); // Needed to avoid buggy HTTP2 implementation in Freshdesk
    $url = curl_getinfo($curl, CURLINFO_EFFECTIVE_URL);

    // Execute call
    $curl_response_with_header = curl_exec($curl);
    if ($curl_response_with_header === false) {
        throw new \Exception("API Call to " . parse_url($url)['host'] . "... failed: " . curl_error($curl), 1);
    }

    $header_size = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
    $headers = substr($curl_response_with_header, 0, $header_size);
    $curl_response = substr($curl_response_with_header, $header_size);
    $curl_reponse_statuscode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
    curl_close($curl);

    // Extract headers
    $headers = explode("\n", $headers);
    foreach ($headers as $header) {
        if (str_contains($header, ':')) {
            list($key, $value) = explode(':', $header, 2);
            $headers[trim(strtolower($key))] = trim($value);
            if (preg_match_all('/charset[\:|=]\s{0,1}(\S*)/i', $value, $matches)) {
                $encoding = $matches[1][0];
            } elseif (stristr($key, 'blacklisted')) {
                throw new \Exception(parse_url($url, PHP_URL_HOST) . ' responded that your IP is blacklisted');
            }
        }
    }

    // Do we have a rate limit? if yes wait and retry
    if ($GLOBALS['SDK_RETRY_ON_RATE_LIMIT'] && $curl_reponse_statuscode == 429) {
        if ((int) ($headers['x-ratelimit-reset'] ?? 0)) {
            $delay = (int) $headers['x-ratelimit-reset'] - time();
        } elseif ((int) ($headers['retry-after'] ?? 0)) {
            $delay = (int) $headers['retry-after'] + 1;
        } else {
            $delay = 60;
        }

        // Rate limit requests retry after $delay seconds. Sleeping..."
        sleep($delay);

        return self::call($method, $url, $headers, $params);
    } elseif ($curl_reponse_statuscode < 200 || $curl_reponse_statuscode >= 400) {
        if (str_starts_with($url, $_ENV['ENNEO_API_URL'])) {
            // For internal Enneo API calls, we show the original error message directly
            throw new Exception($curl_response);
        } else {
            throw new Exception("Api call to $url failed with code $curl_reponse_statuscode and response: $curl_response");
        }
    }

    // Is the string a JSON?
    if (str_starts_with($curl_response, '{') || str_starts_with($curl_response, '[')) {
        // Convert into UTF8 used by PHP
        if (isset($encoding) && $encoding != 'utf-8') {
            $curl_response = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($match) {
                return mb_convert_encoding(pack('H*', $match[1]), 'UTF-8', 'UCS-2BE');
            }, $curl_response);
        }

        // Parse data
        $res = json_decode($curl_response);
        if (is_null($res)) {
            throw new \Exception("Could not decode JSON String $curl_response returned from $url, Error Code " . json_last_error(), 1);
        }

        return $res;
    } else {
        // Special case when a REST API does not respond with a json-encoded response. Should not happen in modern times.
        return (object) ['response' => $curl_response, 'code' => $curl_reponse_statuscode];
    }
}
}

class Setting
{
/**
 * Helper class for getting settings from Enneo through the API.
 *
 * @param string $settingName The name of the setting, e.g. 'powercloudApiUrl'
 * @return mixed The setting value
 * @throws Exception
 */
public static function get(string $settingName): mixed
{
    return ApiEnneo::get('/api/mind/settings/compact?showSecrets=true&filterByName=' . $settingName, authorizeAs: 'serviceWorker')->$settingName ?? null;
}

/**
 * @param string $settingName
 * @param mixed $value
 * @return void
 * @throws Exception
 */
public static function set(string $settingName, mixed $value): void
{
    $setting = new stdClass();
    $setting->$settingName = $value;
    ApiEnneo::post('/api/mind/settings', $setting);
}
}

/**
* Helper class for calling the Powercloud API.
* Authorization is handled by this SDK.
*/
class ApiPowercloud
{
/**
 * @throws Exception
 */
public static function getCall(string $endpoint): object|array
{
    $url = Setting::get('powercloudApiUrl') . $endpoint;
    $headers = ['Authorization: Basic ' . Setting::get('powercloudApiAuth')];

    $result = Api::call("GET", $url, $headers);

    return $result;
}

/**
 * @throws Exception
 */
public static function postCall(string $endpoint, array|object $params): object|array
{
    $url = Setting::get('powercloudApiUrl') . $endpoint;
    $headers = ['Authorization: Basic ' . Setting::get('powercloudApiAuth')];

    $result = Api::call("POST", $url, $headers, $params);

    return $result;
}

/**
 * Extract the error message from a Powercloud API response.
 *
 * @param object|string $result The powercloud API response as object or string
 * @return string A human-readable error message
 */
public static function extractError(object|string $result): string
{
    $message = 'Powercloud-Fehler: ';
    if (!is_object($result)) {
        $message .= $result;
    } elseif (sizeof($result->errors ?? []) > 0) {
        foreach ($result->errors as $error) {
            $message .= $error->messageLocalized . '; ';
        }
    } elseif (isset($result->response) && is_string($result->response)) {
        $message .= $result->response;
    } elseif (isset($result->messageLocalized)) {
        $message .= $result->messageLocalized;
    } else {
        $message .= print_r($result, 1);
    }

    return $message;
}
}

class Helpers
{
/**
 * Format a date for display in german or english.
 *
 * @param string|DateTime $date   The date to format
 * @param string          $format The language code of the format. Either 'de' or 'en'
 *
 * @return string The date in a country-formatted style, e.g. 01.01.2021 or 2021-01-01
 *
 * @throws Exception
 */
public static function formatDate(string|DateTime $date, string $format = 'de'): string
{
    // $date must be formatted as mysql timestamp (yyyy-mm-dd)
    if (is_string($date)) {
        $d = DateTime::createFromFormat('Y-m-d', $date);
        if (!$d) {
            $d = DateTime::createFromFormat('Y-m-d H:i:s', $date);
        }
        if (!$d && $date) {
            return $date;
        }
        if (!$d) {
            if ($date === '') {
                throw new Exception('No date to format');
            } else {
                throw new Exception("Date '$date' invalid");
            }
        }
    } else {
        $d = $date;
    }

    if ($format == 'en') {
        return $d->format('Y-m-d');
    } elseif ($format == 'de') {
        return $d->format('d.m.Y');
    } else {
        throw new Exception("Unknown language $format");
    }
}

/**
 * Parse a textual date into yyyy-mm-dd format.
 *
 * @param string $textualDate The textual date, e.g. 2021-01-01 or 01.01.2021 or 1.1.2021 or 1.1.21
 * @return ?string The date in yyyy-mm-dd format or null if the date format is invalid
 */
public static function parseDateToYMD(string $str): ?string
{
    $parsedDate = date_parse($str);

    // Check if the parsing was successful
    if ($parsedDate['error_count'] == 0 && $parsedDate['warning_count'] == 0 && $parsedDate['year'] > 0 && $parsedDate['month'] > 0 && $parsedDate['day'] > 0) {
        // Format and return the date as yyyy-mm-dd
        return sprintf('%04d-%02d-%02d', $parsedDate['year'], $parsedDate['month'], $parsedDate['day']);
    } else {
        $yearOfText = date('Y');
        $str .= ' '; // Needed to capture dates and the end of the subject

        // Get format dd.mm.yyyy
        if (preg_match_all('/\s+(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])\.((?:19|20)?(?:1|2)\d)[^\d]/', $str, $m)) {
            foreach ($m[0] as $key => $val) {
                $day = str_pad($m[1][$key], 2, '0', STR_PAD_LEFT);
                $month = str_pad($m[2][$key], 2, '0', STR_PAD_LEFT);
                $year = (strlen($m[3][$key]) == 2) ? '20' . $m[3][$key] : $m[3][$key];
                $date = new DateTime($year . '-' . $month . '-' . $day);
                // $date = DateTime::createFromFormat('Y-m-d', $date)
                if ($date) {
                    return $date->format('Y-m-d');
                }
            }
        }

        // Get format dd.mm. and dd.mm
        if (preg_match_all('/\s+(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])(?:\.|\s)[^\d]/', $str, $m)) {
            foreach ($m[0] as $key => $val) {
                $day = str_pad($m[1][$key], 2, '0', STR_PAD_LEFT);
                $month = str_pad($m[2][$key], 2, '0', STR_PAD_LEFT);
                $year = $yearOfText;
                $date = new DateTime($year . '-' . $month . '-' . $day);
                if ($date) {
                    return $date->format('Y-m-d');
                }
            }
        }

        return null;
    }
}

/**
 * Convert a string to a boolean value.
 *
 * @param mixed $val The value to convert to a boolean
 * @param bool|null $default The default value to return if the input value is null or an empty string
 * @return bool The converted boolean value
 */

public static function boolval(mixed $val, ?bool $default = null): bool
{
    if ($val === 'false' || $val === '0') {
        return false;
    } elseif ($val === 'true' || $val === '1') {
        return true;
    } elseif (!is_null($default) && (is_null($val) || $val === '')) {
        return $default;
    } else {
        $boolval = (is_string($val) ? filter_var($val, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : (bool) $val);
    }

    return (bool) $boolval;
}
}