diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml
new file mode 100644
index 0000000..fead572
--- /dev/null
+++ b/.github/workflows/changelog.yaml
@@ -0,0 +1,29 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/changelog.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Changelog
+###
+### Checks that changelog has been updated
+
+name: Changelog
+
+on:
+ pull_request:
+
+jobs:
+ changelog:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 2
+
+ - name: Git fetch
+ run: git fetch
+
+ - name: Check that changelog has been updated.
+ run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0
diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml
new file mode 100644
index 0000000..5fb0ef0
--- /dev/null
+++ b/.github/workflows/composer.yaml
@@ -0,0 +1,85 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/composer.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Composer
+###
+### Validates composer.json and checks that it's normalized.
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `phpfpm` can be run and `composer` can be
+### run inside the `phpfpm` service.
+### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize)
+### is a dev requirement in `composer.json`:
+###
+### ``` shell
+### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize
+### ```
+###
+### Normalize `composer.json` by running
+###
+### ``` shell
+### docker compose run --rm phpfpm composer normalize
+### ```
+
+name: Composer
+
+env:
+ COMPOSE_USER: root
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ composer-validate:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm phpfpm composer validate --strict
+
+ composer-normalized:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer init --no-interaction
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer require mglaman/composer-drupal-lenient
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm rm composer.lenient.*
+
+ docker compose run --rm phpfpm composer install
+ docker compose run --rm phpfpm composer normalize --dry-run
+
+ composer-audit:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm phpfpm composer audit
diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml
new file mode 100644
index 0000000..ae83163
--- /dev/null
+++ b/.github/workflows/markdown.yaml
@@ -0,0 +1,44 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/markdown.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Markdown
+###
+### Lints Markdown files (`**/*.md`) in the project.
+###
+### [markdownlint-cli configuration
+### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration),
+### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually
+### linted and how.
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `markdownlint` for running `markdownlint`
+### (from
+### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli))
+### exists.
+
+name: Markdown
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ markdown-lint:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm markdownlint markdownlint '**/*.md'
diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml
new file mode 100644
index 0000000..3b6415d
--- /dev/null
+++ b/.github/workflows/php.yaml
@@ -0,0 +1,64 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/drupal-module/php.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### Drupal module PHP
+###
+### Checks that PHP code adheres to the [Drupal coding
+### standards](https://www.drupal.org/docs/develop/standards).
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `phpfpm` can be run and `composer` can be
+### run inside the `phpfpm` service.
+### 2. [drupal/coder](https://www.drupal.org/project/coder) is a dev requirement
+### in `composer.json`:
+###
+### ``` shell
+### docker compose run --rm phpfpm composer require --dev drupal/coder
+### ```
+###
+### Clean up and check code by running
+###
+### ``` shell
+### docker compose run --rm phpfpm vendor/bin/phpcbf
+### docker compose run --rm phpfpm vendor/bin/phpcs
+### ```
+###
+### > [!NOTE]
+### > The template adds `.phpcs.xml.dist` as [a configuration file for
+### > PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage#using-a-default-configuration-file)
+### > and this makes it possible to override the actual configuration used in a
+### > project by adding a more important configuration file, e.g. `.phpcs.xml`.
+
+name: PHP
+
+env:
+ COMPOSE_USER: root
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ coding-standards:
+ name: PHP - Check Coding Standards
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer init --no-interaction
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer require mglaman/composer-drupal-lenient
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm rm composer.lenient.*
+
+ docker compose run --rm phpfpm composer install
+ docker compose run --rm phpfpm vendor/bin/phpcs
diff --git a/.github/workflows/project.yaml b/.github/workflows/project.yaml
new file mode 100644
index 0000000..da5ae3d
--- /dev/null
+++ b/.github/workflows/project.yaml
@@ -0,0 +1,30 @@
+name: Project
+
+env:
+ COMPOSE_USER: root
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ code-analysis:
+ name: PHP - Code analysis
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - run: |
+ ./scripts/code-analysis
+
+ rector:
+ name: PHP - Rector
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - run: |
+ ./scripts/rector
diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml
new file mode 100644
index 0000000..631e525
--- /dev/null
+++ b/.github/workflows/yaml.yaml
@@ -0,0 +1,41 @@
+# Do not edit this file! Make a pull request on changing
+# github/workflows/yaml.yaml in
+# https://github.com/itk-dev/devops_itkdev-docker if need be.
+
+### ### YAML
+###
+### Validates YAML files.
+###
+### #### Assumptions
+###
+### 1. A docker compose service named `prettier` for running
+### [Prettier](https://prettier.io/) exists.
+###
+### #### Symfony YAML
+###
+### Symfony's YAML config files use 4 spaces for indentation and single quotes.
+### Therefore we use a [Prettier configuration
+### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make
+### Prettier format YAML files in the `config/` folder like Symfony expects.
+
+name: YAML
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ yaml-lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Create docker network
+ run: |
+ docker network create frontend
+
+ - run: |
+ docker compose run --rm prettier '**/*.{yml,yaml}' --check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d8a7996
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+composer.lock
+vendor/
diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc
new file mode 100644
index 0000000..0253096
--- /dev/null
+++ b/.markdownlint.jsonc
@@ -0,0 +1,22 @@
+// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker.
+// Feel free to edit the file, but consider making a pull request if you find a general issue with the file.
+
+// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration)
+{
+ "default": true,
+ // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
+ "line-length": {
+ "line_length": 120,
+ "code_blocks": false,
+ "tables": false
+ },
+ // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md
+ "no-duplicate-heading": {
+ "siblings_only": true
+ },
+ // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section
+ // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md
+ "no-inline-html": {
+ "allowed_elements": ["details", "summary"]
+ }
+}
diff --git a/.markdownlintignore b/.markdownlintignore
new file mode 100644
index 0000000..d143ace
--- /dev/null
+++ b/.markdownlintignore
@@ -0,0 +1,12 @@
+# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker.
+# Feel free to edit the file, but consider making a pull request if you find a general issue with the file.
+
+# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files
+vendor/
+node_modules/
+LICENSE.md
+# Drupal
+web/*.md
+web/core/
+web/libraries/
+web/*/contrib/
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
new file mode 100644
index 0000000..083aa41
--- /dev/null
+++ b/.phpcs.xml.dist
@@ -0,0 +1,33 @@
+
+
+
+
+
+ The coding standard.
+
+ .
+
+
+ vendor
+ rector.php
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/Settings/
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b337319
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+[Unreleased]: https://github.com/itk-dev/os2forms_f2
diff --git a/README.md b/README.md
index 8537ebd..999451d 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,10 @@
# OS2Forms: F2 integration
+
+1. Install the module
+
+ ``` shell
+ drush pm:install os2forms_f2
+ ```
+
+2. Go to `/admin/os2forms_f2/settings` and define settings.
+3. Add a "F2" handler to a webform.
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 0000000..b4d390e
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,118 @@
+# https://taskfile.dev
+
+version: "3"
+
+tasks:
+ compose:
+ cmds:
+ - docker compose {{.TASK_ARGS}} {{.CLI_ARGS}}
+ internal: true
+
+ composer:
+ desc: Run composer inside docker compose setup, e.g. `task {{.TASK}} -- install`
+ cmds:
+ - task: compose
+ vars:
+ TASK_ARGS: run --rm phpfpm composer {{.TASK_ARGS}}
+
+ composer:install:
+ desc: Run composer inside docker compose setup, e.g. `task {{.TASK}} -- install`
+ cmds:
+ - rm -fr composer.lock vendor
+ - |
+ # Create a temporary composer file to install https://github.com/mglaman/composer-drupal-lenient before the real install needs it.
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer init --no-interaction
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer require mglaman/composer-drupal-lenient
+ docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm rm composer.lenient.*
+
+ - task: composer
+ vars:
+ TASK_ARGS: install
+
+ coding-standards:apply:
+ desc: "Apply coding standards"
+ cmds:
+ - task: coding-standards:composer:apply
+ - task: coding-standards:markdown:apply
+ - task: coding-standards:php:apply
+ - task: coding-standards:yaml:apply
+ silent: true
+
+ coding-standards:check:
+ desc: "Check coding standards"
+ cmds:
+ - task: coding-standards:composer:check
+ - task: coding-standards:markdown:check
+ - task: coding-standards:php:check
+ - task: coding-standards:yaml:check
+ silent: true
+
+ coding-standards:composer:apply:
+ desc: "Apply coding standards for composer"
+ cmds:
+ - task: compose
+ vars:
+ TASK_ARGS: run --rm phpfpm composer normalize
+
+ coding-standards:composer:check:
+ - task: coding-standards:composer:apply
+ - task: compose
+ vars:
+ TASK_ARGS: run --rm phpfpm composer normalize --dry-run
+ - task: compose
+ vars:
+ TASK_ARGS: run --rm phpfpm composer validate
+
+ coding-standards:markdown:apply:
+ desc: "Apply coding standards for Markdown"
+ cmds:
+ # Cf. .github/workflows/markdown.yaml
+ - docker compose run --rm markdownlint markdownlint '**/*.md' --fix
+
+ coding-standards:markdown:check:
+ desc: "Apply and check coding standards for Markdown"
+ cmds:
+ - task: coding-standards:markdown:apply
+ # Cf. .github/workflows/markdown.yaml
+ - docker compose run --rm markdownlint markdownlint '**/*.md'
+
+ coding-standards:php:apply:
+ desc: "Apply coding standards for PHP"
+ cmds:
+ # Cf. .github/workflows/php.yaml
+ - docker compose run --rm phpfpm vendor/bin/phpcbf
+ silent: true
+
+ coding-standards:php:check:
+ desc: "Apply and check coding standards for PHP"
+ cmds:
+ - task: coding-standards:php:apply
+ # Cf. .github/workflows/php.yaml
+ - docker compose run --rm phpfpm vendor/bin/phpcs
+ silent: true
+
+ coding-standards:yaml:apply:
+ desc: "Apply coding standards for YAML"
+ cmds:
+ # Cf. .github/workflows/yaml.yaml
+ - docker compose run --rm prettier '**/*.{yml,yaml}' --write
+
+ coding-standards:yaml:check:
+ desc: "Apply coding standards for YAML"
+ cmds:
+ - task: coding-standards:yaml:apply
+ # Cf. .github/workflows/yaml.yaml
+ - docker compose run --rm prettier '**/*.{yml,yaml}' --check
+
+ test:
+ cmds:
+ - docker compose run --env PHP_XDEBUG_MODE --env PHP_XDEBUG_WITH_REQUEST
+ --env PHP_IDE_CONFIG --rm phpfpm vendor/bin/phpunit {{.CLI_ARGS}}
+
+ xdebug:test:
+ cmds:
+ - PHP_XDEBUG_MODE=debug PHP_XDEBUG_WITH_REQUEST=yes
+ PHP_IDE_CONFIG=serverName=localhost docker compose run --env
+ PHP_XDEBUG_MODE --env PHP_XDEBUG_WITH_REQUEST --env PHP_IDE_CONFIG --rm
+ phpfpm vendor/bin/phpunit {{.CLI_ARGS}}
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..c4f59d0
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,32 @@
+services:
+ phpfpm:
+ image: itkdev/php8.3-fpm:latest
+ user: ${COMPOSE_USER:-deploy}
+ profiles:
+ - dev
+ volumes:
+ - .:/app-os2forms_f2
+ working_dir: /app-os2forms_f2
+ environment:
+ # https://getcomposer.org/doc/03-cli.md#composer-no-security-blocking
+ # @see https://github.com/OS2Forms/os2forms/issues/245
+ COMPOSER_NO_SECURITY_BLOCKING: 1
+
+ # Code checks tools
+ markdownlint:
+ image: itkdev/markdownlint
+ profiles:
+ - dev
+ volumes:
+ - ./:/md
+
+ prettier:
+ # Prettier does not (yet, fcf.
+ # https://github.com/prettier/prettier/issues/15206) have an official
+ # docker image.
+ # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc)
+ image: jauderho/prettier
+ profiles:
+ - dev
+ volumes:
+ - ./:/work
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..41ed126
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,32 @@
+{
+ "name": "os2forms/os2forms_f2",
+ "description": "OS2Forms: F2 integration",
+ "license": "MIT",
+ "type": "drupal-module",
+ "require": {
+ "itk-dev/drupal_psr6_cache": "^1.1",
+ "itk-dev/f2-api-client": "dev-f2-api-client as 1.0.0",
+ "os2forms/os2forms": "^5.0"
+ },
+ "require-dev": {
+ "drupal/coder": "^9.0",
+ "ergebnis/composer-normalize": "^2.52"
+ },
+ "repositories": [
+ {
+ "type": "composer",
+ "url": "https://packages.drupal.org/8"
+ }
+ ],
+ "minimum-stability": "dev",
+ "config": {
+ "allow-plugins": {
+ "cweagans/composer-patches": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "ergebnis/composer-normalize": true,
+ "mglaman/composer-drupal-lenient": true,
+ "simplesamlphp/composer-module-installer": true,
+ "zaporylie/composer-drupal-optimizations": true
+ }
+ }
+}
diff --git a/config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml b/config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml
new file mode 100644
index 0000000..a0c1842
--- /dev/null
+++ b/config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml
@@ -0,0 +1,16 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - os2forms_f2
+ enforced:
+ module:
+ - os2forms_f2
+id: os2forms_f2
+label: "OSForms F2"
+backend: database
+backend_configuration:
+ lease_time: 300
+processor: cron
+processing_time: 90
+locked: false
diff --git a/os2forms_f2.info.yml b/os2forms_f2.info.yml
new file mode 100644
index 0000000..5422217
--- /dev/null
+++ b/os2forms_f2.info.yml
@@ -0,0 +1,16 @@
+name: "os2forms_f2"
+type: module
+description: "OS2Forms: F2 integration"
+package: OS2Forms
+core_version_requirement: ^10 || ^11
+dependencies:
+ - drupal_psr6_cache:drupal_psr6_cache
+ - os2forms:os2forms
+
+configure: os2forms_f2.admin.settings
+
+"interface translation project": os2forms_f2
+# @todo Using module://%project/ here does not work …
+# @todo Set a proper URL when the module has been released for real
+# "interface translation server pattern": module://%project/translations/%project.%language.po
+"interface translation server pattern": https://github.com/itk-dev/os2forms_f2/blob/os2forms_f2/translations/os2forms_f2.da.po
diff --git a/os2forms_f2.links.menu.yml b/os2forms_f2.links.menu.yml
new file mode 100644
index 0000000..4b72a0c
--- /dev/null
+++ b/os2forms_f2.links.menu.yml
@@ -0,0 +1,5 @@
+os2forms_f2.admin.settings:
+ title: OS2Forms F2
+ description: Configure the OS2Forms F2 module
+ parent: system.admin_config_system
+ route_name: os2forms_f2.admin.settings
diff --git a/os2forms_f2.routing.yml b/os2forms_f2.routing.yml
new file mode 100644
index 0000000..9102eed
--- /dev/null
+++ b/os2forms_f2.routing.yml
@@ -0,0 +1,7 @@
+os2forms_f2.admin.settings:
+ path: "/admin/os2forms_f2/settings"
+ defaults:
+ _title: "F2 settings"
+ _form: 'Drupal\os2forms_f2\Form\SettingsForm'
+ requirements:
+ _permission: "administer site configuration"
diff --git a/os2forms_f2.services.yml b/os2forms_f2.services.yml
new file mode 100644
index 0000000..f4e0bf6
--- /dev/null
+++ b/os2forms_f2.services.yml
@@ -0,0 +1,17 @@
+services:
+ _defaults:
+ autowire: true
+
+ logger.channel.os2forms_f2:
+ parent: logger.channel_base
+ arguments: ["os2forms_f2"]
+
+ logger.channel.os2forms_f2_submission:
+ parent: logger.channel_base
+ arguments: ["webform_submission"]
+
+ Drupal\os2forms_f2\Helper\F2Helper:
+
+ Drupal\os2forms_f2\Helper\WebformHelperF2:
+
+ Drupal\os2forms_f2\Settings:
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..716b6c6
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,14 @@
+parameters:
+ paths:
+ - .
+ level: 5
+ customRulesetUsed: true
+ reportUnmatchedIgnoredErrors: false
+ excludePaths:
+ - rector.php
+ # Ignore any vendor folder (https://phpstan.org/user-guide/ignoring-errors#excluding-whole-files)
+ - vendor (?)
+
+ ignoreErrors:
+ - '#Call to method Drupal\\Core\\Entity\\Query\\QueryInterface::accessCheck\(\) will always evaluate to true.#'
+ - '#Call to method PHPUnit\\Framework\\Assert::assertTrue\(\) with true will always evaluate to true.#'
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..44fe530
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,21 @@
+withPaths([
+ __DIR__ . '/src',
+ // __DIR__ . '/tests',
+ ])
+ ->withSets([
+ Drupal10SetList::DRUPAL_10,
+ ])
+ ->withPhpSets(php83: TRUE)
+ ->withTypeCoverageLevel(0);
diff --git a/scripts/.env b/scripts/.env
new file mode 100644
index 0000000..da1e572
--- /dev/null
+++ b/scripts/.env
@@ -0,0 +1,2 @@
+COMPOSE_PROJECT_NAME=drupal-module
+MODULE_NAME=os2forms_f2
diff --git a/scripts/base b/scripts/base
new file mode 100644
index 0000000..def2c9d
--- /dev/null
+++ b/scripts/base
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+set -o errexit -o errtrace -o noclobber -o nounset -o pipefail
+IFS=$'\n\t'
+
+execute_name=execute
+
+usage() {
+ (cat >&2 </dev/null); then
+ (cat >&2 <&2 <queueStorage = $entityTypeManager->getStorage('advancedqueue_queue');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return 'os2forms_f2_settings';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames(): array {
+ return [Settings::CONFIG_NAME];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+ $form = parent::buildForm($form, $form_state);
+
+ $form[F2ApiSettings::NAME] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('F2 API'),
+ '#tree' => TRUE,
+ ] + $this->buildFormF2Api();
+
+ $form[GeneralSettings::NAME] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('General'),
+ '#tree' => TRUE,
+ ] + $this->buildFormGeneral();
+
+ $form[self::ACTION_PING_API] = [
+ '#type' => 'container',
+ '#weight' => 10000,
+
+ self::ACTION_PING_API => [
+ '#type' => 'submit',
+ '#name' => self::ACTION_PING_API,
+ '#value' => $this->t('Ping API'),
+ ],
+
+ 'message' => [
+ '#markup' => $this->t('Note: Pinging the API will use saved config.'),
+ ],
+ ];
+
+ return $form;
+ }
+
+ /**
+ * Build form section "F2 API".
+ */
+ private function buildFormF2Api(): array {
+ $settings = $this->settings->getF2ApiSettings();
+
+ $section[F2ApiSettings::URI] = [
+ '#type' => 'url',
+ '#required' => TRUE,
+ '#title' => $this->t('URI'),
+ '#default_value' => $settings->uri,
+ '#description' => $this->t('The F2 API base URI'),
+ ];
+
+ $section[F2ApiSettings::USERNAME] = [
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ '#title' => $this->t('Username'),
+ '#default_value' => $settings->username,
+ '#description' => $this->t('The F2 API username'),
+ ];
+
+ $section[F2ApiSettings::SECRET] = [
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ '#title' => $this->t('Secret'),
+ '#default_value' => $settings->secret,
+ '#description' => $this->t('The F2 API secret'),
+ ];
+
+ $section[F2ApiSettings::F2_USERNAME] = [
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ '#title' => $this->t('F2 username'),
+ '#default_value' => $settings->f2Username,
+ '#description' => $this->t('The F2 username to act on behalf of'),
+ ];
+
+ return $section;
+ }
+
+ /**
+ * Build form section "General".
+ */
+ private function buildFormGeneral(): array {
+ $settings = $this->settings->getGeneralSettings();
+
+ $description = empty($settings->queue)
+ ? $this->t('Queue for F2 jobs.')
+ : $this->t("Queue for F2 jobs. The queue must be run via Drupal's cron or via drush advancedqueue:queue:process @queue (in a cron job).",
+ [
+ '@queue' => $settings->queue,
+ ':queue_url' => '/admin/config/system/queues/jobs/' . urlencode((string) $settings->queue),
+ ]);
+ $section[GeneralSettings::QUEUE] = [
+ '#type' => 'select',
+ '#required' => TRUE,
+ '#title' => $this->t('Queue'),
+ '#options' => array_map(
+ static fn(EntityInterface $queue) => $queue->label(),
+ $this->queueStorage->loadMultiple()
+ ),
+ '#empty_option' => $this->t('No queue'),
+ '#default_value' => $settings->queue,
+ '#description' => $description,
+ ];
+
+ $section[GeneralSettings::TEST_MODE] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Test mode'),
+ '#default_value' => $settings->testMode,
+ ];
+
+ return $section;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function validateForm(array &$form, FormStateInterface $form_state): void {
+ if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) {
+ return;
+ }
+
+ parent::validateForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function submitForm(array &$form, FormStateInterface $form_state): void {
+ if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) {
+ try {
+ $this->f2->pingApi();
+ $this->messenger()->addStatus($this->t('Pinged API successfully.'));
+ }
+ catch (\Throwable $t) {
+ $this->messenger()->addError($this->t('Pinging API failed: @message', ['@message' => $t->getMessage()]));
+ }
+ return;
+ }
+
+ $config = $this->config(Settings::CONFIG_NAME);
+ foreach ([
+ F2ApiSettings::NAME,
+ GeneralSettings::NAME,
+ ] as $name) {
+ $config->set($name, $form_state->getValue($name));
+ }
+ $config->save();
+
+ parent::submitForm($form, $form_state);
+ }
+
+}
diff --git a/src/Helper/F2Helper.php b/src/Helper/F2Helper.php
new file mode 100644
index 0000000..16b54c6
--- /dev/null
+++ b/src/Helper/F2Helper.php
@@ -0,0 +1,49 @@
+apiClient) {
+ $settings = $this->settings->getF2ApiSettings();
+ $this->apiClient = new ApiClient([
+ 'api_uri' => $settings->uri,
+ 'api_username' => $settings->username,
+ 'api_secret' => $settings->secret,
+ 'f2_username' => $settings->f2Username,
+ 'cache_item_pool' => $this->cacheItemPool,
+ ]);
+ }
+
+ return $this->apiClient;
+ }
+
+ /**
+ *
+ */
+ public function pingApi(): void {
+ $this->client()->matterSearch('ping');
+ }
+
+}
diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php
new file mode 100644
index 0000000..931233f
--- /dev/null
+++ b/src/Helper/WebformHelperF2.php
@@ -0,0 +1,284 @@
+getStorage('webform_submission');
+ $this->webformSubmissionStorage = $storage;
+ $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue');
+ }
+
+ /**
+ * Load webform submission by id.
+ */
+ public function loadSubmission(int $id): ?WebformSubmissionInterface {
+ /** @var ?WebformSubmissionInterface $submission */
+ $submission = $this->webformSubmissionStorage->load($id);
+
+ return $submission;
+ }
+
+ /**
+ * Load queue.
+ */
+ private function loadQueue(): QueueInterface {
+ $id = $this->settings->getGeneralSettings()->queue ?? NULL;
+
+ /** @var ?\Drupal\advancedqueue\Entity\QueueInterface $queue */
+ $queue = $this->queueStorage->load($id);
+
+ if (NULL === $queue) {
+ throw new RuntimeException(sprintf('Cannot load queue %s', $id));
+ }
+
+ return $queue;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param mixed $level
+ * The level.
+ * @param string $message
+ * The message.
+ * @param array $context
+ * The context.
+ *
+ * @phpstan-param array $context
+ */
+ public function log($level, $message, array $context = []): void {
+ $this->logger->log($level, $message, $context);
+ // @see https://www.drupal.org/node/3020595
+ if (isset($context['webform_submission']) && $context['webform_submission'] instanceof WebformSubmissionInterface) {
+ $this->submissionLogger->log($level, $message, $context);
+ }
+ }
+
+ /**
+ * Create a job.
+ *
+ * @see self::processJob()
+ */
+ public function createJob(WebformSubmissionInterface $webformSubmission, WebformHandlerF2|ArchiveSettings $handlerSettings, ?string $state = NULL, ?array $payload = []): ?Job {
+ $context = [
+ 'handler_id' => WebformHandlerF2::ID,
+ 'webform_submission' => $webformSubmission,
+ ];
+
+ try {
+ if ($handlerSettings instanceof WebformHandlerF2) {
+ $handlerSettings = $this->settings->getHandlerSettings($handlerSettings);
+ }
+
+ $job = Job::create(F2::class, [
+ 'formId' => $webformSubmission->getWebform()->id(),
+ 'submissionId' => $webformSubmission->id(),
+ 'handlerSettings' => $handlerSettings->toArray(),
+ ]);
+ $queue = $this->loadQueue();
+ $queue->enqueueJob($job);
+ $context['@queue'] = $queue->id();
+ $this->notice('F2 job added to the queue @queue.', $context);
+
+ return $job;
+ }
+ catch (\Exception $exception) {
+ $this->error('Error creating job for F2 archive: %message', $context + [
+ '%message' => $exception->getMessage(),
+ 'operation' => 'F2 failed',
+ 'exception' => $exception,
+ ]);
+ return NULL;
+ }
+ }
+
+ /**
+ * Process a job.
+ *
+ * @see self::createJob()
+ */
+ public function processJob(Job $job): JobResult {
+ $payload = $job->getPayload();
+ $context = [
+ 'handler_id' => WebformHandlerF2::ID,
+ 'operation' => 'F2 archive',
+ ];
+ try {
+ $submissionId = $payload['submissionId'];
+ $submission = $this->loadSubmission($submissionId);
+ if (NULL === $submission) {
+ $message = 'Cannot load submission @submissionId';
+ $context = [
+ '@submissionId' => $submissionId,
+ ];
+ $this->error($message, $context);
+
+ throw new SubmissionNotFoundException(str_replace(array_keys($context), array_values($context),
+ $message));
+ }
+
+ $context['webform_submission'] = $submission;
+ $handlerSettings = new HandlerSettings($payload['handlerSettings']);
+ $this->replaceTokens($handlerSettings, $submission);
+
+ $target = $handlerSettings->archive?->archiveTarget;
+ return match ($target) {
+ ArchiveTarget::MatterID => $this->archiveOnMatter($submission, $handlerSettings),
+ default => throw new RuntimeException(sprintf('Invalid archive target: %s', $target->name)),
+ };
+ }
+ catch (\Exception $exception) {
+ $this->error('Error: @message', $context + [
+ '@message' => $exception->getMessage(),
+ 'exception' => $exception,
+ ]);
+
+ return JobResult::failure($exception->getMessage());
+ }
+ }
+
+ /**
+ * Replace tokens in handler settings supporting tokens.
+ */
+ private function replaceTokens(HandlerSettings $handlerSettings, WebformSubmissionInterface $submission): HandlerSettings {
+ // @todo Should we clone the settings before making changes?
+ $handlerSettings->archive->documentTitle = $this->webformTokenManager->replace((string) $handlerSettings->archive->documentTitle, $submission);
+
+ return $handlerSettings;
+ }
+
+ /**
+ *
+ */
+ private function archiveOnMatter(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult {
+ $matterId = $handlerSettings->archive?->archiveTargetMatter?->matterId;
+ if (NULL === $matterId) {
+ throw new RuntimeException('Cannot get matter ID');
+ }
+ $matter = $this->f2->client()->matterById($matterId);
+ $attachment = $this->getAttachment($submission, $handlerSettings);
+ $message = '';
+ try {
+ // Apparently the F2 API inspects the filename extension to determine file type, i.e. we must keep the extension in the temporary filename.
+ $filePath = $this->fileSystem->saveData($attachment->contents, 'temporary://' . uniqid('os2forms_f2') . '-' . $attachment->filename);
+ $document = new Document();
+ $document->title = $handlerSettings->archive?->documentTitle ?? $attachment->filename;
+ $document = $this->f2->client()->documentCreate($document, $filePath, $matter);
+ $message = sprintf('Document %s created on matter %s', $document, $matter);
+ }
+ finally {
+ if (isset($filePath) && file_exists($filePath)) {
+ unlink($filePath);
+ }
+ }
+
+ return JobResult::success($message);
+ }
+
+ /**
+ * Get main document.
+ *
+ * @throws InvalidAttachmentElementException
+ *
+ * @see WebformAttachmentController::download()
+ */
+ protected function getAttachment(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): ?Attachment {
+ // Lifted from Drupal\webform_attachment\Controller\WebformAttachmentController::download.
+ $element = $handlerSettings->archive->attachmentElement;
+ if (NULL === $element) {
+ throw new InvalidAttachmentElementException('Cannot get attachment element');
+ }
+ $element = $submission->getWebform()->getElement($element) ?: [];
+ if (!isset($element['#type'])) {
+ throw new InvalidAttachmentElementException(sprintf('Cannot get attachment element %s', $element));
+ }
+ [$type] = explode(':', $element['#type']);
+ $instance = $this->elementInfoManager->createInstance($type);
+
+ if (!$instance instanceof WebformAttachmentBase) {
+ throw new InvalidAttachmentElementException(sprintf('Attachment element must be an instance of %s. Found %s.', WebformAttachmentBase::class, $instance::class));
+ }
+
+ $fileName = $instance::getFileName($element, $submission);
+ $mimeType = $instance::getFileMimeType($element, $submission);
+
+ if (self::PDF_MIME_TYPE !== $mimeType) {
+ throw new InvalidAttachmentElementException(sprintf('The attachment element must be a PDF file (%s); got %s.', self::PDF_MIME_TYPE, $mimeType));
+ }
+
+ $content = $instance::getFileContent($element, $submission);
+
+ return new Attachment(
+ $content,
+ $mimeType,
+ $fileName
+ );
+ }
+
+}
diff --git a/src/Model/Attachment.php b/src/Model/Attachment.php
new file mode 100644
index 0000000..e1064bf
--- /dev/null
+++ b/src/Model/Attachment.php
@@ -0,0 +1,28 @@
+mimeType;
+ }
+
+}
diff --git a/src/Plugin/AdvancedQueue/JobType/F2.php b/src/Plugin/AdvancedQueue/JobType/F2.php
new file mode 100644
index 0000000..e0705ab
--- /dev/null
+++ b/src/Plugin/AdvancedQueue/JobType/F2.php
@@ -0,0 +1,62 @@
+ $configuration
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get(WebformHelperF2::class)
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $configuration
+ */
+ public function __construct(
+ array $configuration,
+ $plugin_id,
+ $plugin_definition,
+ /**
+ * The webform helper.
+ */
+ private readonly WebformHelperF2 $helper,
+ ) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process(Job $job): JobResult {
+ return $this->helper->processJob($job);
+ }
+
+}
diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php
new file mode 100644
index 0000000..1f0d03b
--- /dev/null
+++ b/src/Plugin/WebformHandler/WebformHandlerF2.php
@@ -0,0 +1,250 @@
+settingsService = $container->get(Settings::class);
+ $instance->helper = $container->get(WebformHelperF2::class);
+ $instance->f2 = $container->get(F2Helper::class);
+
+ return $instance;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOffCanvasWidth(): string {
+ return WebformDialogHelper::DIALOG_NONE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+ $settings = $this->settingsService->getArchiveSettings((array) ($this->getSetting(ArchiveSettings::NAME)));
+
+ $form[ArchiveSettings::NAME] = [
+ ArchiveSettings::ATTACHMENT_ELEMENT => [
+ '#type' => 'select',
+ '#required' => TRUE,
+ '#title' => $this->t('Attachment element'),
+ '#default_value' => $settings->attachmentElement,
+ '#options' => $this->getAttachmentElements(),
+ ],
+
+ ArchiveSettings::DOCUMENT_TITLE => [
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ '#title' => $this->t('Document title'),
+ '#default_value' => $settings->documentTitle,
+ '#description' => $this->t('The title of the document. Tokens can be used in the title, e.g. [webform_submission:label].'),
+ ],
+
+ ArchiveSettings::ARCHIVE_TARGET => [
+ '#type' => 'select',
+ '#required' => TRUE,
+ '#title' => $this->t('Archive target'),
+ '#default_value' => $settings->archiveTarget?->value,
+ '#options' => [
+ ArchiveTarget::MatterID->value => $this->t('Matter ID'),
+ ],
+ ],
+
+ ArchiveTargetMatter::NAME => [
+ ArchiveTargetMatter::MATTER_ID => [
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ '#title' => $this->t('Matter ID'),
+ '#default_value' => $settings->archiveTargetMatter?->matterId,
+
+ '#states' => [
+ 'visible' => [
+ ':input[name="settings[' . ArchiveSettings::NAME . '][' . ArchiveTargetMatter::NAME . ']"]' => [
+ 'value' => ArchiveTarget::MatterID->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ if (ArchiveTarget::MatterID === $settings->archiveTarget) {
+ $matterId = $settings->archiveTargetMatter?->matterId;
+ if (NULL !== $matterId) {
+ try {
+ $matter = $this->f2->client()->matterById($matterId);
+
+ $form[ArchiveSettings::NAME][ArchiveTargetMatter::NAME]['details'] = [
+ '#type' => 'details',
+ '#open' => TRUE,
+ '#title' => $this->t('Matter'),
+ '#markup' => $matter,
+ ];
+ }
+ catch (\Throwable $e) {
+ // Ignore all errors.
+ }
+ }
+ }
+
+ return parent::buildConfigurationForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+ parent::validateConfigurationForm($form, $form_state);
+
+ $setError = static fn(string|array $path, TranslatableMarkup $message) => $form_state->setErrorByName(implode('][', (array) $path), $message);
+
+ $target = $form_state->getValue([ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET]);
+ if (ArchiveTarget::MatterID->value === $target) {
+ $key = [ArchiveSettings::NAME, ArchiveTargetMatter::NAME, ArchiveTargetMatter::MATTER_ID];
+ $matterId = trim((string) $form_state->getValue($key));
+ $matterId = filter_var($matterId, FILTER_VALIDATE_INT);
+ if (FALSE === $matterId) {
+ $setError($key, t('Missing or invalid matter ID.'));
+ }
+ else {
+ try {
+ $this->f2->client()->matterById($matterId);
+ }
+ catch (\Throwable $throwable) {
+ $setError($key, t('Cannot get matter by ID @matter_id (@message).', [
+ '@matter_id' => $matterId,
+ '@message' => $throwable->getMessage(),
+ ]));
+ }
+ }
+ }
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+ parent::submitConfigurationForm($form, $form_state);
+ foreach ([
+ ArchiveSettings::NAME,
+ ] as $name) {
+ $this->configuration[$name] = $form_state->getValue($name);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE) {
+ // Run only when submission is completed.
+ // @todo Run on update?
+ if (!$webform_submission->isCompleted()) {
+ return;
+ }
+
+ $this->helper->createJob($webform_submission, $this);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function getSummary() {
+ $settings = $this->settingsService->getHandlerSettings($this);
+
+ $build = [
+ 'info' => [
+ '#prefix' => '',
+ '#suffix' => '
',
+ ],
+ ];
+
+ switch ($settings->archive?->archiveTarget) {
+ case ArchiveTarget::MatterID:
+ $matterId = $settings->archive->archiveTargetMatter?->matterId;
+ if ($matterId) {
+ $build['info'][ArchiveTargetMatter::NAME] = [
+ '#markup' => $this->t('Archive on matter @matter_id', ['@matter_id' => $matterId]),
+ ];
+ }
+ break;
+ }
+
+ return $build;
+ }
+
+ /**
+ * Get attachment elements.
+ *
+ * @phpstan-return array
+ */
+ private function getAttachmentElements(): array {
+ $elements = $this->getWebform()->getElementsDecodedAndFlattened();
+
+ $elementTypes = [
+ 'webform_entity_print_attachment:pdf',
+ 'os2forms_attachment',
+ ];
+ $elements = array_filter(
+ $elements,
+ static fn(array $element) => in_array($element['#type'], $elementTypes, TRUE)
+ );
+
+ return array_map(static fn(array $element) => $element['#title'], $elements);
+ }
+
+}
diff --git a/src/Settings.php b/src/Settings.php
new file mode 100644
index 0000000..c0ca0af
--- /dev/null
+++ b/src/Settings.php
@@ -0,0 +1,78 @@
+config = $configFactory->get(self::CONFIG_NAME);
+ }
+
+ /**
+ * Get F2 API settings.
+ */
+ public function getF2ApiSettings(): F2ApiSettings {
+ return new F2ApiSettings($this->getValue(F2ApiSettings::NAME));
+ }
+
+ /**
+ * Get general settings.
+ */
+ public function getGeneralSettings(): GeneralSettings {
+ return new GeneralSettings($this->getValue(GeneralSettings::NAME));
+ }
+
+ /**
+ * Get handler settings.
+ */
+ public function getArchiveSettings(array $values = []): ArchiveSettings {
+ return (new ArchiveSettings($this->getValue(ArchiveSettings::NAME)))
+ ->apply($values);
+ }
+
+ /**
+ * Get handler settings.
+ *
+ * The settings are the global settings with handler specific settings on top.
+ */
+ public function getHandlerSettings(WebformHandlerF2 $handler): HandlerSettings {
+ $handlerSettings = $handler->getSettings();
+
+ return new HandlerSettings([
+ HandlerSettings::HANDLER_ID => $handler->gethandlerId(),
+ HandlerSettings::ARCHIVE => $this->getArchiveSettings($handlerSettings[ArchiveSettings::NAME] ?? []),
+ ]);
+ }
+
+ /**
+ * Get settings value.
+ *
+ * @return array
+ * The settings values.
+ */
+ private function getValue(string $section): array {
+ $values = $this->config->get($section);
+
+ return is_array($values) ? $values : [];
+ }
+
+}
diff --git a/src/Settings/AbstractSettings.php b/src/Settings/AbstractSettings.php
new file mode 100644
index 0000000..5255d0b
--- /dev/null
+++ b/src/Settings/AbstractSettings.php
@@ -0,0 +1,173 @@
+
+ * $settingsProperties = [
+ * 'items' => SomeNestedSettings::class,
+ * ];
+ *
+ *
+ * @var array
+ */
+ protected static array $settingsProperties = [];
+
+ /**
+ * List properties.
+ *
+ * Map from name to AbstractSettings type, e.g.
+ *
+ *
+ * $listProperties = [
+ * 'items' => SomeNestedSettings::class,
+ * ];
+ *
+ *
+ * @var array
+ */
+ protected static array $listProperties = [];
+
+ /**
+ * Enum properties.
+ *
+ * Map from name to Backed enum type, e.g.
+ *
+ *
+ * $enumProperties = [
+ * 'type' => MyType::class,
+ * ];
+ *
+ *
+ * @var array
+ */
+ protected static array $enumProperties = [];
+
+ /**
+ * Nullable properties.
+ *
+ * List of properties that must be set to null if the value is a blank string.
+ *
+ * @var array
+ */
+ protected static array $nullableProperties = [];
+
+ /**
+ * The values.
+ *
+ * @var array
+ */
+ protected array $values;
+
+ /**
+ * Constructor.
+ *
+ * @param array $values
+ * The values.
+ * @param bool $throwExceptionOnMissingProperty
+ * If set, an exception is thrown when setting an undefined property.
+ * If not set, undefined properties are silently ignored.
+ */
+ public function __construct(array $values, bool $throwExceptionOnMissingProperty = FALSE) {
+ $this->values = [];
+ $this->apply($values, $throwExceptionOnMissingProperty);
+ }
+
+ /**
+ * Apply values to settings.
+ */
+ public function apply(array $values, bool $throwExceptionOnMissingProperty = FALSE): static {
+ foreach (static::$listProperties as $property => $class) {
+ if (isset($values[$property]) && is_array($values[$property])) {
+ $values[$property] = array_map(static fn(array $vals) => new $class($vals), $values[$property]);
+ }
+ }
+
+ foreach (static::$settingsProperties as $property => $class) {
+ if (isset($values[$property]) && is_array($values[$property])) {
+ $values[$property] = new $class($values[$property]);
+ }
+ }
+
+ foreach (static::$enumProperties as $property => $class) {
+ if (isset($values[$property]) && is_scalar($values[$property])) {
+ $values[$property] = $class::tryFrom($values[$property]);
+ }
+ }
+
+ foreach ($values as $key => $value) {
+ $name = self::kebab2camel($key);
+ if (!property_exists($this, $name)) {
+ if ($throwExceptionOnMissingProperty) {
+ throw new \RuntimeException(
+ $name !== $key
+ ? sprintf('Property "%s" ("%s") does not exist in class %s.',
+ $name, $key, static::class)
+ : sprintf('Property "%s" does not exist in class %s.', $name,
+ static::class)
+ );
+ }
+ else {
+ continue;
+ }
+ }
+ if (isset(static::$nullableProperties[$key]) && empty(trim((string) $value))) {
+ $value = NULL;
+ }
+ $this->$name = $value;
+ $this->values[self::camel2kebab($name)] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Convert settings to array.
+ */
+ public function toArray(bool $recursive = TRUE): array {
+ $values = $this->values;
+
+ if ($recursive) {
+ foreach ($values as &$value) {
+ if ($value instanceof self) {
+ $value = $value->toArray($recursive);
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function jsonSerialize(): array {
+ return $this->toArray();
+ }
+
+ /**
+ * Convert kebab_case to camelCase.
+ */
+ public static function kebab2camel(string $value): string {
+ return lcfirst(str_replace('_', '', ucwords($value, '_')));
+ }
+
+ /**
+ * Convert camelCase to kebab_case.
+ *
+ * @see https://stackoverflow.com/a/40514305/2502647
+ */
+ public static function camel2kebab(string $value): string {
+ return strtolower((string) preg_replace('/(?<=\d)(?=[A-Za-z])|(?<=[A-Za-z])(?=\d)|(?<=[a-z])(?=[A-Z])/', '_', $value));
+ }
+
+}
diff --git a/src/Settings/ArchiveSettings.php b/src/Settings/ArchiveSettings.php
new file mode 100644
index 0000000..68cf7cb
--- /dev/null
+++ b/src/Settings/ArchiveSettings.php
@@ -0,0 +1,37 @@
+ ArchiveTarget::class,
+ ];
+
+ protected static array $settingsProperties = [
+ self::ARCHIVE_TARGET_MATTER => ArchiveTargetMatter::class,
+ ];
+
+ const string HANDLER_ID = 'handler_id';
+ public string $handlerId;
+
+ const string ATTACHMENT_ELEMENT = 'attachment_element';
+ public ?string $attachmentElement = NULL;
+
+ const string DOCUMENT_TITLE = 'document_title';
+ public ?string $documentTitle = NULL;
+
+ const string ARCHIVE_TARGET = 'archive_target';
+ public ?ArchiveTarget $archiveTarget = NULL;
+
+ const string ARCHIVE_TARGET_MATTER = 'archive_target_matter';
+ public ?ArchiveTargetMatter $archiveTargetMatter = NULL;
+
+}
diff --git a/src/Settings/ArchiveSettings/ArchiveTarget.php b/src/Settings/ArchiveSettings/ArchiveTarget.php
new file mode 100644
index 0000000..374013e
--- /dev/null
+++ b/src/Settings/ArchiveSettings/ArchiveTarget.php
@@ -0,0 +1,10 @@
+ ArchiveSettings::class,
+ ];
+
+ const string HANDLER_ID = 'handler_id';
+ public string $handlerId;
+
+ const string ARCHIVE = 'archive';
+ public ?ArchiveSettings $archive = NULL;
+
+}