From 4b137df328fc4ce1df169636d0fc68843f64d1b1 Mon Sep 17 00:00:00 2001 From: Anne Jan Brouwer Date: Thu, 7 Sep 2023 22:46:41 +0200 Subject: [PATCH] v1.0.6 --- .db_requirements | 2 +- .env | 34 + .env.ci | 15 + .env.development | 13 + .github/workflows/documentation-linter.yml | 4 +- .github/workflows/php-tests.yml | 33 + .github/workflows/robotframeworkci.yml | 2 - .gitignore | 1 + .npmrc | 2 +- Makefile | 22 +- assets/admin.js | 6 +- assets/app.js | 3 + assets/dropzone.js | 4 +- assets/init.js | 1 + assets/styles/admin/admin-base.scss | 367 ++-- assets/styles/admin/admin-decision-link.scss | 43 +- assets/styles/admin/admin-decisions.scss | 44 +- assets/styles/admin/admin-signin.scss | 104 +- assets/styles/admin/admin-variables.scss | 113 +- assets/styles/admin/admin.scss | 44 +- assets/styles/components/all.scss | 25 +- assets/styles/components/arrowed-link.scss | 34 +- assets/styles/components/block-link.scss | 34 +- assets/styles/components/button.scss | 55 +- assets/styles/components/form-filters.scss | 78 +- assets/styles/components/form.scss | 2 + assets/styles/components/gallery.scss | 42 +- assets/styles/components/link.scss | 37 +- assets/styles/components/list-densed.scss | 6 +- assets/styles/components/logo.scss | 14 +- assets/styles/components/main-navigation.scss | 25 + assets/styles/components/pagination.scss | 66 +- assets/styles/components/pill.scss | 23 +- assets/styles/components/search-form.scss | 30 +- assets/styles/components/split-link.scss | 30 +- assets/styles/components/table.scss | 26 +- assets/styles/helpers.scss | 82 +- assets/woopie.js | 3 +- composer.json | 13 +- composer.lock | 1933 ++++++++++++----- config/bundles.php | 1 + config/packages/doctrine.yaml | 1 + config/packages/flysystem.yaml | 48 +- config/packages/framework.yaml | 8 +- config/packages/messenger.yaml | 4 +- config/packages/monolog.yaml | 22 +- config/packages/security.yaml | 26 +- config/packages/snc_redis.yaml | 33 +- config/packages/twig.yaml | 7 +- config/routes.yaml | 53 +- config/routes/scheb_2fa.yaml | 4 +- config/services.yaml | 47 +- docker/Dockerfile | 4 +- docker/woopie.conf | 2 + docs/install.md | 2 +- docs/usage.md | 4 +- package-lock.json | 324 ++- package.json | 22 +- public/placeholder.png | Bin 77589 -> 1579 bytes ruleset.phpmd.xml | 2 +- src/Citation.php | 73 +- src/Command/CleanSheet.php | 6 + src/Command/GenerateDocuments.php | 2 +- src/Command/LoadFixture.php | 1 - src/Command/UploadDocument.php | 10 +- src/Command/User/Create.php | 4 +- src/Command/Where.php | 4 +- src/Controller/Admin/DepartmentController.php | 8 +- .../Admin/Dossier/DocumentController.php | 79 +- .../Admin/Dossier/DossierController.php | 175 +- src/Controller/Admin/ElasticController.php | 8 +- src/Controller/Admin/PrefixController.php | 4 +- src/Controller/Admin/RequestController.php | 4 +- src/Controller/Admin/TokenController.php | 4 +- src/Controller/Admin/UserController.php | 12 +- src/Controller/DocumentController.php | 95 +- src/Controller/DossierController.php | 168 +- src/Controller/HomeController.php | 5 +- src/Controller/InquiryController.php | 35 +- src/Controller/SearchController.php | 34 +- src/Controller/SecurityController.php | 6 +- src/Controller/StatsController.php | 109 +- src/DataCollector/ElasticCollector.php | 3 + src/DataFixtures/DepartmentFixtures.php | 30 +- src/Entity/BatchDownload.php | 5 + src/Entity/Department.php | 20 + src/Entity/Document.php | 274 +-- src/Entity/Dossier.php | 162 +- src/Entity/EntityWithId.php | 2 +- src/Entity/Inquiry.php | 4 +- src/Entity/Inventory.php | 60 +- .../ChangePasswordSubscriber.php | 2 +- .../PaginationCountSubscriber.php | 2 +- .../SecurityHeaderSubscriber.php | 70 +- src/Exception/FixtureInventoryException.php | 6 +- src/Form/ChoiceTypeWithHelp.php | 2 +- src/Form/DepartmentType.php | 19 +- src/Form/Document/IngestFormType.php | 2 +- src/Form/Dossier/DossierType.php | 79 +- src/Form/Dossier/SearchFormType.php | 7 +- src/Form/GovernmentOfficialType.php | 2 +- src/Form/User/UserCreateFormType.php | 8 +- src/Form/User/UserRoleFormType.php | 2 +- src/Message/ProcessDocumentMessage.php | 11 +- src/MessageHandler/GenerateArchiveHandler.php | 3 + src/MessageHandler/IngestAudioHandler.php | 3 + src/MessageHandler/IngestDossiersHandler.php | 3 + src/MessageHandler/IngestPdfHandler.php | 3 + src/MessageHandler/IngestPdfPageHandler.php | 3 + .../InitializeElasticRolloverHandler.php | 3 + src/MessageHandler/ProcessDocumentHandler.php | 49 +- src/MessageHandler/SetElasticAliasHandler.php | 3 + .../UpdateDepartmentHandler.php | 3 + src/MessageHandler/UpdateDossierHandler.php | 3 + src/MessageHandler/UpdateOfficialHandler.php | 3 + src/Repository/BatchDownloadRepository.php | 10 + src/Repository/DocumentRepository.php | 159 +- src/Repository/DossierRepository.php | 26 +- src/Roles.php | 8 + src/Service/ArchiveService.php | 91 +- src/Service/DocumentService.php | 143 +- src/Service/DossierService.php | 261 ++- src/Service/Elastic/ElasticClientFactory.php | 5 + src/Service/Elastic/ElasticService.php | 220 +- src/Service/Elastic/IndexService.php | 12 +- src/Service/Elastic/Model/Index.php | 1 + src/Service/FakeDataGenerator.php | 45 +- src/Service/FileUploader.php | 40 +- src/Service/FixtureService.php | 42 +- src/Service/Ingest/Handler.php | 5 +- src/Service/Ingest/Handler/AudioHandler.php | 17 +- src/Service/Ingest/Handler/PdfHandler.php | 33 +- src/Service/Ingest/IngestLogger.php | 11 +- src/Service/Ingest/IngestService.php | 4 +- src/Service/Search/ConfigFactory.php | 20 +- .../Search/Model/AggregationBucketEntry.php | 17 +- src/Service/Search/Model/Config.php | 15 + src/Service/Search/Object/ObjectHandler.php | 34 +- .../AggregationStrategyInterface.php | 11 +- .../Aggregation/TermsAggregationStrategy.php | 41 +- .../Search/Query/Filter/AndTermFilter.php | 29 +- .../Search/Query/Filter/DateRangeFilter.php | 39 +- .../Search/Query/Filter/FilterInterface.php | 11 +- .../Search/Query/Filter/OrTermFilter.php | 31 +- src/Service/Search/Query/QueryGenerator.php | 247 +-- src/Service/Search/Result/Result.php | 29 + .../Search/Result/ResultTransformer.php | 59 +- src/Service/Search/SearchService.php | 20 +- src/Service/SqlDump/NodeVisitor.php | 4 + .../Storage/DocumentStorageService.php | 65 +- .../Storage/ThumbnailStorageService.php | 50 + .../Worker/Audio/Extractor/AudioExtractor.php | 2 + .../Audio/Extractor/WaveImageExtractor.php | 4 +- .../Extractor/DocumentContentExtractor.php | 2 + .../Pdf/Extractor/PageContentExtractor.php | 2 + .../Worker/Pdf/Extractor/PageExtractor.php | 10 + .../Pdf/Extractor/PagecountExtractor.php | 9 + .../Pdf/Extractor/ThumbnailExtractor.php | 4 +- src/SourceType.php | 3 + src/Twig/Extension/AppExtension.php | 1 + src/Twig/Extension/DateExtension.php | 2 +- src/Twig/Extension/WooExtension.php | 4 + src/Twig/Runtime/AppExtensionRuntime.php | 13 +- src/Twig/Runtime/WooExtensionRuntime.php | 124 +- symfony.lock | 3 + tailwind.config.js | 21 +- templates/admin.html.twig | 51 +- .../admin/departments/edit-head.html.twig | 4 +- templates/admin/departments/edit.html.twig | 4 +- templates/admin/departments/index.html.twig | 6 + .../admin/dossier/document-details.html.twig | 34 +- .../admin/dossier/document-status.html.twig | 9 +- templates/admin/dossier/documents.html.twig | 2 +- templates/admin/dossier/edit.html.twig | 8 +- templates/admin/dossier/index.html.twig | 51 +- templates/admin/dossier/new.html.twig | 38 +- templates/admin/elastic/index.html.twig | 83 +- templates/admin/index.html.twig | 36 +- templates/admin/prefix/edit.html.twig | 4 +- templates/admin/request/index.html.twig | 12 +- templates/admin/stats/index.html.twig | 62 +- templates/admin/token/edit.html.twig | 4 +- templates/admin/user/edit.html.twig | 4 +- templates/base.html.twig | 43 +- templates/document/details.html.twig | 82 +- templates/document/snippets/about.html.twig | 69 +- .../document/snippets/attachments.html.twig | 40 +- .../document/snippets/background.html.twig | 61 +- .../document/snippets/carousel.html.twig | 43 +- templates/document/snippets/family.html.twig | 11 +- .../snippets/other-documents.html.twig | 11 +- .../snippets/other-dossiers.html.twig | 38 +- templates/document/snippets/thread.html.twig | 15 +- templates/dossier/batch.html.twig | 8 +- templates/dossier/details.html.twig | 155 +- templates/dossier/index.html.twig | 68 +- templates/facet_macros.html.twig | 82 +- templates/home/index.html.twig | 135 +- templates/inquiry/index.html.twig | 115 +- templates/pagination.html.twig | 77 +- templates/search/browse-facets.html.twig | 22 +- .../search/entries-minimalistic.html.twig | 2 +- templates/search/entries.html.twig | 69 +- templates/search/entries/document.html.twig | 31 +- templates/search/entries/dossier.html.twig | 27 +- templates/search/entries/page.html.twig | 36 +- templates/search/facet.html.twig | 34 +- templates/search/filter.html.twig | 2 +- templates/search/result-failure.html.twig | 36 +- templates/search/result.html.twig | 20 +- templates/search/searchbar.html.twig | 16 +- templates/security/login.html.twig | 8 +- templates/security/login_2fa.html.twig | 6 +- tests/Fixtures/000-inventory-001.xlsx | Bin 13797 -> 16301 bytes tests/Fixtures/001-inquiry.json | 134 +- tests/Fixtures/001-inventory-001.xlsx | Bin 12856 -> 16015 bytes tests/Fixtures/001-inventory-002.xlsx | Bin 12845 -> 15985 bytes tests/Fixtures/001-inventory-003.xlsx | Bin 12817 -> 15973 bytes tests/Fixtures/README.md | 3 +- tests/Unit/Service/DocumentServiceTest.php | 49 +- tests/Unit/Service/DossierServiceTest.php | 221 +- .../Unit/Service/Ingest/IngestServiceTest.php | 21 +- .../Service/Storage/DocumentStorageTest.php | 2 +- tests/robot_framework/citesten.robot | 301 +-- tests/robot_framework/e2etesten.robot | 31 +- translations/messages+intl-icu.en.yaml | 709 +++--- translations/messages+intl-icu.nl.yaml | 707 +++--- webpack.config.js | 7 + 228 files changed, 7984 insertions(+), 3760 deletions(-) diff --git a/.db_requirements b/.db_requirements index 45c7a584..03ac6403 100644 --- a/.db_requirements +++ b/.db_requirements @@ -1 +1 @@ -v0.0.1 +v0.0.13 diff --git a/.env b/.env index 3d1e57b3..68cdeb6c 100644 --- a/.env +++ b/.env @@ -13,6 +13,9 @@ APP_SECRET=32f3c49be690d4c5f499093ae7dd3a7d # Database at-rest encryption key (generated with "php bin/console generate:database-key") DATABASE_ENCRYPTION_KEY= +# The name of the site. Used only for displaying purposes. +SITE_NAME=open.minvws.nl + # ----------------------------------------------------- # External service configuration # ----------------------------------------------------- @@ -45,6 +48,9 @@ TIKA_HOST=http://127.0.0.1:9998 # Redis instance that is used for document content caching REDIS_URL=redis://localhost:6379 +REDIS_TLS_CAFILE= +REDIS_TLS_LOCAL_CERT= +REDIS_TLS_LOCAL_PK= # The name of cookie. Should start with __Host- , but cannot be prefixed # with __Host- when running on non-TLS connections @@ -52,3 +58,31 @@ COOKIE_NAME=WOOPID # Issuer of the TOTP tokens, used in 2fa for the totp URI TOTP_ISSUER=localhost + +# Application mode. Could be only for balie (backend), frontend, or both +APP_MODE=both + +# Base URL of the application frontend (which could different from backend when APP_MODE is not BOTH) +PUBLIC_BASE_URL=http://localhost:8000 + +# ----------------------------------------------------- +# Storage adapter to use +# Choose between aws or local +STORAGE_DOCUMENT_ADAPTER=local +STORAGE_THUMBNAIL_ADAPTER=local +STORAGE_BATCH_ADAPTER=local + +# Storage adapter configuration for AWS S3/Minio +STORAGE_MINIO_REGION=eu-west-1 +STORAGE_MINIO_ENDPOINT= +STORAGE_MINIO_ACCESS_KEY= +STORAGE_MINIO_SECRET_KEY= + +# Bucket definitions for AWS S3/Minio +STORAGE_MINIO_DOCUMENT_BUCKET=doc_bucket +STORAGE_MINIO_THUMBNAIL_BUCKET=thumb_bucket +STORAGE_MINIO_BATCH_BUCKET=batch_bucket + +# ----------------------------------------------------- +# Identification number for Piwik analytics +PIWIK_ANALYTICS_ID=0 diff --git a/.env.ci b/.env.ci index 64a563bf..2c974b90 100644 --- a/.env.ci +++ b/.env.ci @@ -3,6 +3,8 @@ APP_ENV=dev APP_SECRET=32f3c49be690d4c5f499093ae7dd3a7d +SITE_NAME=open.minvws.nl + DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?serverVersion=15&charset=utf8" HIGH_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/high @@ -20,7 +22,20 @@ ELASTICSEARCH_MTLS_CA_PATH= TIKA_HOST=http://localhost:9998 REDIS_URL=redis://localhost:6379 +REDIS_TLS_CAFILE= +REDIS_TLS_LOCAL_CERT= +REDIS_TLS_LOCAL_PK= COOKIE_NAME=WOOPID TOTP_ISSUER=localhost + +APP_MODE=both +PUBLIC_BASE_URL=http://localhost:8000 + + +STORAGE_DOCUMENT_ADAPTER=local +STORAGE_THUMBNAIL_ADAPTER=local +STORAGE_BATCH_ADAPTER=local + +PIWIK_ANALYTICS_ID=0 diff --git a/.env.development b/.env.development index 202f708e..5a9848af 100644 --- a/.env.development +++ b/.env.development @@ -3,6 +3,8 @@ APP_ENV=dev APP_SECRET=32f3c49be690d4c5f499093ae7dd3a7d +SITE_NAME=open.minvws.nl + DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres?serverVersion=15&charset=utf8" HIGH_TRANSPORT_DSN=amqp://guest:guest@rabbitmq:5672/%2f/high @@ -26,8 +28,19 @@ TIKA_HOST=http://tika:9998 DATABASE_ENCRYPTION_KEY= REDIS_URL=redis://redis:6379 +REDIS_TLS_CAFILE= +REDIS_TLS_LOCAL_CERT= +REDIS_TLS_LOCAL_PK= COOKIE_NAME=WOOPID TOTP_ISSUER=localhost +APP_MODE=both +PUBLIC_BASE_URL=http://localhost:8000 + +STORAGE_DOCUMENT_ADAPTER=local +STORAGE_THUMBNAIL_ADAPTER=local +STORAGE_BATCH_ADAPTER=local + +PIWIK_ANALYTICS_ID=0 diff --git a/.github/workflows/documentation-linter.yml b/.github/workflows/documentation-linter.yml index 1a6815f8..0d9c53b8 100644 --- a/.github/workflows/documentation-linter.yml +++ b/.github/workflows/documentation-linter.yml @@ -8,8 +8,8 @@ jobs: name: lint markDown file runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: DavidAnson/markdownlint-cli2-action@v11 + - uses: actions/checkout@v3 + - uses: DavidAnson/markdownlint-cli2-action@v12 with: globs: '**/*.md' diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index 5f0aaf69..d558663e 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -148,3 +148,36 @@ jobs: max-parallel: 3 matrix: php-versions: [ '8.1', '8.2' ] + + php-linting-twig: + needs: + - composer-install + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/cache@v3 + with: + path: vendor/ + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install PHP + uses: shivammathur/setup-php@master + with: + php-version: ${{ matrix.php-versions }} + - name: copy env file + run: | + cp .env.ci .env.local + # change database url to sqlite + sed -i 's|^DATABASE_URL=.*|DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db|' .env.local + - name: Twig linter + run: | + bin/console cache:clear + bin/console cache:warmup + bin/console lint:twig templates + env: + APP_ENV: prod + APP_DEBUG: false + strategy: + max-parallel: 3 + matrix: + php-versions: [ '8.1', '8.2' ] diff --git a/.github/workflows/robotframeworkci.yml b/.github/workflows/robotframeworkci.yml index f94b1480..60e07fb7 100644 --- a/.github/workflows/robotframeworkci.yml +++ b/.github/workflows/robotframeworkci.yml @@ -1,8 +1,6 @@ name: Robot Framework tests on: - push: - branches: [ main ] pull_request: branches: [ main ] diff --git a/.gitignore b/.gitignore index 77369483..a07108b9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ yarn-error.log # /database +/public/sitemap* diff --git a/.npmrc b/.npmrc index b822692d..4d644ad0 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -@minvws:registry=https://npm.pkg.github.com +@minvws:registry=https://npm.pkg.github.com \ No newline at end of file diff --git a/Makefile b/Makefile index b00b5f5c..e75ca103 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SHELL=/usr/bin/env bash -O globstar all: help -test: test_phpcs test_phpstan test_phpcsfixer test_phpmd test_unit test_psalm ## Runs tests +test: test_phpcs test_phpstan test_phpcsfixer test_phpmd test_unit test_psalm test_twig test_markdown ## Runs tests test_phpcs: source test-utils.sh ;\ @@ -36,6 +36,18 @@ test_unit: ## Run unit tests section "PHPUNIT" ;\ vendor/bin/phpunit --testsuite "Woopie Unit Test Suite" +test_twig: ## Run twig linter + source test-utils.sh ;\ + section "TWIG-LINT" ;\ + APP_DEBUG=false APP_ENV=prod php bin/console cache:clear + APP_DEBUG=false APP_ENV=prod php bin/console cache:warmup + APP_DEBUG=false APP_ENV=prod php bin/console lint:twig templates + +test_markdown: ## Lint markdown files + source test-utils.sh ;\ + section "MARKDOWN-LINT" ;\ + npm run mdlint + fix: ## Fixes coding style vendor/bin/php-cs-fixer fix vendor/bin/phpcbf @@ -71,3 +83,11 @@ test-rf/%: ## Run Robot Framework tests with matching tag test-rf-head/%: ## Run Robot Framework with browser visible, with matching tag env/bin/python -m robot -d tests/robot_framework/results -x outputxunit.xml -i $* -v headless:false tests/robot_framework + +update: ## Update code / db/ assets, for instance after git pull + composer install + bin/console doctrine:migrations:migrate --no-interaction + npm install + npm run build + vendor/bin/phpdotenvsync --opt=sync --src=.env.development --dest=.env.local --no-interaction + diff --git a/assets/admin.js b/assets/admin.js index 87f53eb0..92db4320 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -1 +1,5 @@ -import './styles/admin/admin.scss'; \ No newline at end of file +// Styling +import './styles/admin/admin.scss'; + +// JS +import '@minvws/manon/collapsible.js'; diff --git a/assets/app.js b/assets/app.js index 4dffd788..3f3d7a01 100644 --- a/assets/app.js +++ b/assets/app.js @@ -3,3 +3,6 @@ import './styles/login.css'; import './init.js'; import '@minvws/manon/collapsible.js'; +import "./navigation.js" +import './search/init.js'; +import './tabs.js'; diff --git a/assets/dropzone.js b/assets/dropzone.js index a70bb199..27d3bd79 100644 --- a/assets/dropzone.js +++ b/assets/dropzone.js @@ -7,13 +7,13 @@ Dropzone.options.uploadform = { autoProcessQueue: true, uploadMultiple: false, addRemoveLinks: true, - maxFiles: 100, + maxFiles: 2000, maxFilesize: 4096, // MB chunking: true, parallelChunkUploads: true, retryChunks: true, retryChunksLimit: 3, - chunkSize: 50 * 1024 * 1024, // Bytes + chunkSize: 16 * 1024 * 1024, // Bytes timeout: 0, dictDefaultMessage: "Drop PDF or ZIP files here to upload your documents", acceptedFiles: "application/pdf,application/x-pdf,.zip", diff --git a/assets/init.js b/assets/init.js index 03a97b2a..bc6a990a 100644 --- a/assets/init.js +++ b/assets/init.js @@ -1,5 +1,6 @@ import { onDomReady } from '@minvws/manon/utils.js'; onDomReady(function () { + document.documentElement.classList.remove('no-js'); document.documentElement.classList.add('js'); }); \ No newline at end of file diff --git a/assets/styles/admin/admin-base.scss b/assets/styles/admin/admin-base.scss index 7d468358..aa312653 100644 --- a/assets/styles/admin/admin-base.scss +++ b/assets/styles/admin/admin-base.scss @@ -1,204 +1,299 @@ +@use "@minvws/manon/collapsible"; +@use "@minvws/manon/layout-centered"; + @import "@minvws/manon/footer.scss"; @import "@minvws/manon/link.scss"; @import "@minvws/nl-rdo-rijksoverheid-ui-theme/scss/fonts/ro-icons/sets/base.scss"; @import "@minvws/manon/icon.scss"; +@import "@minvws/manon/button-icon.scss"; +@import "@minvws/manon/visually-hidden.scss"; html * { - box-sizing: border-box; + box-sizing: border-box; } body { - background: $gray-main-bg; - font-family: $font-sans; - line-height: $base-line-height; - margin: 0; - - > header { - align-items: center; - background: $gray-dark; - color: $white; + background: $gray-main-bg; + font-family: $font-sans; + line-height: $base-line-height; + margin: 0; display: flex; flex-direction: column; - flex-shrink: 0; - font-family: $font-sans-italic; - font-size: 22px; - height: 80px; - justify-content: center; - } + min-height: 100vh; - footer { - position: fixed; - top: 100vh; - width: 100%; - transform: translateY(-100%); + > header > nav { + background: $gray-dark; + color: $white; + font-family: $font-sans-italic; + font-size: 22px; + height: 80px; + } + + > header > nav > .nav__container { + width: 100%; + height: 100%; + max-width: 1152px; + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row; + margin: 0 auto; + } + + > header .header__profile { + position: relative; + + ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: row; + align-items: center; + height: 80px; + } + li { + height: 100%; + &:hover, + &:focus { + background: $gray-535353; + } + &:first-child a { + font-family: $font-sans; + } + } + a, span { + text-decoration: none; + height: 100%; + display: flex; + flex-direction: row; + align-items: center; + color: $white; + font-family: $font-sans-bold; + padding: 0 16px; + } + span { + font-family: $font-sans; + } + } + + > header > .header__content { + @include default-big-container; + @include center; + display: flex; + justify-content: space-between; + margin-top: 64px; + margin-bottom: 24px; + } + + > header h1 { + margin: 0; + } + + main { + flex: 1; + + > .content { + margin: 4rem auto 0; + padding: $main-padding; + @include default-big-container; + + &__small { + max-width: $small-content-container; + } + } + } + + footer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + margin-bottom: 32px; + + svg { + margin-bottom: 18px; + + path { + fill: #999; + } + } - ul { - display: flex; - list-style: none; - margin: 0; + ul { + display: flex; + list-style: none; + margin: 0; - li { - margin: 0 9px; - line-height: 1.5; + li { + margin: 0 9px; + line-height: 1.5; - a { - font-size: 0.875rem; - line-height: 1; + a { + font-size: 0.875rem; + line-height: 1; + } + } } - } } - } +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: $font-sans-bold; + font-weight: normal; } strong { - font-family: $font-sans-bold; - font-weight: normal; - font-style: normal; + font-family: $font-sans-bold; + font-weight: normal; + font-style: normal; +} + +input, +textarea { + font-size: 1em; } /* Input */ input[type="text"], input[type="email"], +input[type="date"], input[type="password"], input[type="submit"], -button[type="submit"] { - border-radius: 8px; - color: $color-input; - display: flex; - font-size: 1rem; - line-height: 24px; - padding: 10px 14px; +button[type="submit"], +select, +textarea { + border-radius: 8px; + color: $color-input; + display: flex; + font-family: $font-sans; + line-height: 24px; + padding: 10px 14px; } /* Buttons */ input[type="submit"], button[type="submit"] { - justify-content: center; - color: $white; - font-family: $font-sans; - font-weight: 700; - margin-top: 32px; - margin-bottom: 16px; - line-height: 1.25rem; + justify-content: center; + color: $white; + font-family: $font-sans; + font-weight: 700; + margin-top: 32px; + margin-bottom: 16px; + line-height: 1.25rem; - border: 1px solid $color-button-border; - background: var(--notification-explanation-intense-default, #007BC7); - box-shadow: 0 1px 2px 0 rgba(16, 24, 40, 0.05); + border: 1px solid $color-button-border; + background: var(--notification-explanation-intense-default, #007bc7); + box-shadow: 0 1px 2px 0 rgba(16, 24, 40, 0.05); } /* Text fields */ +input[type="date"], input[type="text"], input[type="email"], -input[type="password"] { - border-radius: 8px; - border: 1px solid var(--form-input-border-color); - background: $white; +input[type="password"], +select, +textarea { + border-radius: 8px; + border: 1px solid var(--form-input-border-color); + background: $white; - /* Shadow/xs */ - box-shadow: $shadow-xs; + /* Shadow/xs */ + box-shadow: $shadow-xs; } label { - font-size: 0.875rem; - margin-top: 16px; -} - -main { - margin: 4rem auto 0; - background: $white; - padding: $main-padding; + font-size: 0.875rem; + margin-top: 16px; } table { - width: 100%; - border-collapse: collapse; + width: 100%; + border-collapse: collapse; } th { - padding: 0.75rem 1.5rem; - border: 1px solid $color-th-border; - background: $color-th-background; - color: $color-th-text; - font-family: $font-sans-italic; - font-size: 1rem; - font-style: italic; - font-weight: 400; - line-height: 18px; - white-space: nowrap; - text-align: left; - - &:first-child { - border-left: none; - } - &:last-child { - border-right: none; - } - - a { - align-items: center; - color: inherit; - display: flex; - gap: $default-gap; - text-decoration: none; - height: 20px; - line-height: 20px; + padding: 0.75rem 1.5rem; + border: 1px solid $color-th-border; + background: $color-th-background; + color: $color-th-text; + font-family: $font-sans-italic; + font-size: 1rem; + font-style: italic; + font-weight: 400; + line-height: 18px; + white-space: nowrap; + text-align: left; - &:hover { - color: $color-th-text-hover; + &:first-child { + border-left: none; + } + &:last-child, + &:nth-last-child(2) { + border-right: none; + border-left: none; + } + + a { + align-items: center; + color: inherit; + display: flex; + gap: $default-gap; + text-decoration: none; + height: 20px; + line-height: 20px; + + &:hover { + color: $color-th-text-hover; + } } - } } tr { - border-bottom: 1px solid $color-tr-border; - - &:hover { - background: $color-tr-hover; - } + border-bottom: 1px solid $color-tr-border; + + &:hover { + background: $color-tr-hover; + } } td { - color: $color-td; - font-size: 0.875rem; - padding: 1rem 1.5rem; - line-height: 1em; + color: $color-td; + font-size: 0.875rem; + padding: 1rem 1.5rem; + line-height: 1em; - span { - min-height: 40px; - display: flex; - align-items: center; - } + span { + min-height: 40px; + display: flex; + align-items: center; + } - height: 72px; + height: 72px; } .content { - background: $white; + background: $white; } -.icon { - font-family: $font-icon; - font-size: 1.125rem; - color: inherit; - position: static; - display: inline-flex; - justify-content: center; - align-items: center; - margin-right: 0.5rem; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; /* Firefox */ - font-smoothing: antialiased; +::placeholder { + /* Chrome, Firefox, Opera, Safari 10.1+ */ + color: $gray-999; + opacity: 1; + /* Firefox */ } -::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ - color: $gray-999; - opacity: 1; /* Firefox */ +::-ms-input-placeholder { + /* Internet Explorer 10-11 */ + color: $gray-999; } -::-ms-input-placeholder { /* Internet Explorer 10-11 */ - color: $gray-999; +::-ms-input-placeholder { + /* Microsoft Edge */ + color: $gray-999; } - -::-ms-input-placeholder { /* Microsoft Edge */ - color: $gray-999; -} \ No newline at end of file diff --git a/assets/styles/admin/admin-decision-link.scss b/assets/styles/admin/admin-decision-link.scss index d2c645e6..8b018985 100644 --- a/assets/styles/admin/admin-decision-link.scss +++ b/assets/styles/admin/admin-decision-link.scss @@ -1,24 +1,27 @@ .decision { - &__case-number { - color: #535353; - font-size: 16px; - white-space: nowrap; - padding-right: 85px; - } + &__case-number { + color: #535353; + font-size: 16px; + white-space: nowrap; + padding-right: 85px; - &__detail-link { - text-decoration: none; - display: flex; - justify-content: center; - align-items: center; + &:after { + color: #999999; + width: 8px; + height: 14px; + padding: 0; + content: ''; + background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjE0IiB2aWV3Qm94PSIwIDAgOCAxNCIgZmlsbD0ibm9uZSI+CiAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0wLjI5Mjg5MyAwLjI5Mjg5M0MwLjY4MzQxNyAtMC4wOTc2MzExIDEuMzE2NTggLTAuMDk3NjMxMSAxLjcwNzExIDAuMjkyODkzTDcuNzA3MTEgNi4yOTI4OUM4LjA5NzYzIDYuNjgzNDIgOC4wOTc2MyA3LjMxNjU4IDcuNzA3MTEgNy43MDcxMUwxLjcwNzExIDEzLjcwNzFDMS4zMTY1OCAxNC4wOTc2IDAuNjgzNDE3IDE0LjA5NzYgMC4yOTI4OTMgMTMuNzA3MUMtMC4wOTc2MzExIDEzLjMxNjYgLTAuMDk3NjMxMSAxMi42ODM0IDAuMjkyODkzIDEyLjI5MjlMNS41ODU3OSA3TDAuMjkyODkzIDEuNzA3MTFDLTAuMDk3NjMxMSAxLjMxNjU4IC0wLjA5NzYzMTEgMC42ODM0MTcgMC4yOTI4OTMgMC4yOTI4OTNaIiBmaWxsPSIjOTk5OTk5Ii8+Cjwvc3ZnPg==) no-repeat; + position: absolute; + right: 30px; + } + } - &:after { - color: #999999; - width: 8px; - height: 14px; - padding: 0; - content: ''; - background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjE0IiB2aWV3Qm94PSIwIDAgOCAxNCIgZmlsbD0ibm9uZSI+CiAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0wLjI5Mjg5MyAwLjI5Mjg5M0MwLjY4MzQxNyAtMC4wOTc2MzExIDEuMzE2NTggLTAuMDk3NjMxMSAxLjcwNzExIDAuMjkyODkzTDcuNzA3MTEgNi4yOTI4OUM4LjA5NzYzIDYuNjgzNDIgOC4wOTc2MyA3LjMxNjU4IDcuNzA3MTEgNy43MDcxMUwxLjcwNzExIDEzLjcwNzFDMS4zMTY1OCAxNC4wOTc2IDAuNjgzNDE3IDE0LjA5NzYgMC4yOTI4OTMgMTMuNzA3MUMtMC4wOTc2MzExIDEzLjMxNjYgLTAuMDk3NjMxMSAxMi42ODM0IDAuMjkyODkzIDEyLjI5MjlMNS41ODU3OSA3TDAuMjkyODkzIDEuNzA3MTFDLTAuMDk3NjMxMSAxLjMxNjU4IC0wLjA5NzYzMTEgMC42ODM0MTcgMC4yOTI4OTMgMC4yOTI4OTNaIiBmaWxsPSIjOTk5OTk5Ii8+Cjwvc3ZnPg==) no-repeat; + &__detail-link { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; } - } -} \ No newline at end of file +} diff --git a/assets/styles/admin/admin-decisions.scss b/assets/styles/admin/admin-decisions.scss index 83055c6c..341cd83c 100644 --- a/assets/styles/admin/admin-decisions.scss +++ b/assets/styles/admin/admin-decisions.scss @@ -1,44 +1,22 @@ @import "admin-decision-link"; .admin-decisions { - main { - width: 100%; - max-width: 1280px; + > .content { + margin-top: 0; } - .form-search-decisions { - padding: 12px 16px; - display: flex; - justify-content: flex-end; + + footer { + margin-top: 160px; } +} - .search-decisions { - display: flex; - - &__input { - display: flex; - width: 424px; - padding: 10px 14px; - align-items: center; - gap: 8px; - - &:placeholder-shown { - text-overflow: ellipsis; - } +.decisions { + tr { + position: relative; } - &__button { - border: none; - color: #999; - background: none; - margin-left: -39px; + td:first-child { + display: flex; + align-items: center; } - } -} - -.decisions { - td:first-child { - display: flex; - align-items: center; - } } diff --git a/assets/styles/admin/admin-signin.scss b/assets/styles/admin/admin-signin.scss index eb9c959f..e3200146 100644 --- a/assets/styles/admin/admin-signin.scss +++ b/assets/styles/admin/admin-signin.scss @@ -1,46 +1,60 @@ .form-signin { - max-width: 360px; - background: $white; - padding: 24px; - margin: 64px auto; - display: flex; - flex-direction: column; - - h1 { - margin-top: 0; - margin-bottom: 16px; - font-size: 28px; - font-family: $font-sans; - } - - [type="submit"] { - border-radius: 8px; - width: 100%; - - /* Shadow/xs */ - box-shadow: 0 1px 2px 0 rgba(16, 24, 40, 0.05); - } - - label { - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - color: $gray-neutral; - margin-bottom: 6px; - } - - p { - line-height: 28px; - margin: 0.5rem 0; - } - - .text-help-primary { - color: $color-helptext-primary; - font-size: 1.125rem; - } - - .text-help-secondary { - color: $color-helptext-secondary; - } -} \ No newline at end of file + max-width: 360px; + background: $white; + padding: 24px; + margin: 64px auto; + display: flex; + flex-direction: column; + + #_auth_code { + margin-bottom: 30px; + } + + h1 { + margin-top: 0; + margin-bottom: 16px; + font-size: 28px; + font-family: $font-sans; + } + + [type="submit"] { + background: var(--notification-explanation-intense-default, #007bc7); + border: none; + color: white; + margin-bottom: 20px; + border-radius: 8px; + width: 100%; + + /* Shadow/xs */ + box-shadow: 0 1px 2px 0 rgba(16, 24, 40, 0.05); + + &:hover, + &:focus { + background: #01689b; + color: white; + } + } + + label { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + color: $gray-neutral; + margin-bottom: 6px; + } + + p { + line-height: 28px; + margin: 0.5rem 0; + } + + .text-help-primary { + color: $color-helptext-primary; + font-size: 1.125rem; + } + + .text-help-secondary { + color: $color-helptext-secondary; + } +} diff --git a/assets/styles/admin/admin-variables.scss b/assets/styles/admin/admin-variables.scss index 45075067..3c958900 100644 --- a/assets/styles/admin/admin-variables.scss +++ b/assets/styles/admin/admin-variables.scss @@ -2,64 +2,72 @@ $ro-font-path: "~@minvws/nl-rdo-rijksoverheid-ui-theme/fonts"; /* Regular */ @font-face { - font-family: "RO Sans Web"; - font-weight: normal; - font-style: normal; - font-display: swap; - src: url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Regular.woff2") format("woff2"), - url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Regular.woff") format("woff"), - url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Regular.ttf") format("truetype"); + font-family: "RO Sans Web"; + font-weight: normal; + font-style: normal; + font-display: swap; + src: url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Regular.woff2") + format("woff2"), + url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Regular.woff") + format("woff"), + url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Regular.ttf") + format("truetype"); } /* Bold */ @font-face { - font-family: "RO Sans Web Bold"; - font-weight: bold; - font-style: normal; - font-display: swap; - src: url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Bold.woff2") format("woff2"), - url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Bold.woff") format("woff"), - url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Bold.ttf") format("truetype"); + font-family: "RO Sans Web Bold"; + font-weight: bold; + font-style: normal; + font-display: swap; + src: url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Bold.woff2") + format("woff2"), + url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Bold.woff") + format("woff"), + url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Bold.ttf") + format("truetype"); } /* Italic */ @font-face { - font-family: "RO Sans Web Italic"; - font-weight: normal; - font-style: italic; - font-display: swap; - src: url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Italic.woff2") format("woff2"), - url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Italic.woff") format("woff"), - url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Italic.ttf") format("truetype"); + font-family: "RO Sans Web Italic"; + font-weight: normal; + font-style: italic; + font-display: swap; + src: url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Italic.woff2") + format("woff2"), + url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Italic.woff") + format("woff"), + url("#{$ro-font-path}/ro-sans-web/RO-SansWebText-Italic.ttf") + format("truetype"); } /* Icon font */ @font-face { - font-family: "RO Icons"; - font-weight: normal; - font-style: normal; - src: url("#{$ro-font-path}/ro-icons/ro-icons-3.6.woff2") format("woff2"), - url("#{$ro-font-path}/ro-icons/ro-icons-3.6.woff") format("woff"), - url("#{$ro-font-path}/ro-icons/ro-icons-3.6.ttf") format("truetype"); + font-family: "RO Icons"; + font-weight: normal; + font-style: normal; + src: url("#{$ro-font-path}/ro-icons/ro-icons-3.6.woff2") format("woff2"), + url("#{$ro-font-path}/ro-icons/ro-icons-3.6.woff") format("woff"), + url("#{$ro-font-path}/ro-icons/ro-icons-3.6.ttf") format("truetype"); } /* Admin colors */ -$gray-light: #CCCCCC; -$gray-blue-x-light: #D0D5DD; +$gray-light: #cccccc; +$gray-blue-x-light: #d0d5dd; $gray-blue-semi-transparent: #08558566; -$gray-dark: #1D1D1D; -$gray-main-bg: #F3F3F3; +$gray-dark: #1d1d1d; +$gray-main-bg: #f3f3f3; $gray-neutral: #696969; $gray-535353: #535353; $gray-667085: #667085; -$gray-F9FAFB: #F9FAFB; +$gray-F9FAFB: #f9fafb; $gray-999: #999999; -$white: #FFFFFF; +$white: #ffffff; $black: #000000; - /* links */ -$link-default: #01689B; +$link-default: #01689b; $link-hover: #004161; /* fonts */ @@ -78,24 +86,47 @@ $color-input: $gray-667085; $color-td: $gray-535353; $color-input-border: $gray-blue-x-light; $color-button-border: $gray-blue-semi-transparent; -$color-th-border: #E6E6E6; -$color-th-background: #F3F3F3; +$color-th-border: var(--gray-2); +$color-th-background: #f3f3f3; $color-th-text: #475467; $color-th-text-hover: #344054; -$color-tr-border: #E6E6E6; -$color-tr-hover: #E5F0F9; +$color-tr-border: var(--gray-2); +$color-tr-hover: #e5f0f9; $color-status-concept: var(--notification-warning-intense-default); $color-status-ready: var(--notification-explanation-intense-default); -$color-status-online: var(--ro-basic-set-orange); +$color-status-online: var(--succes-700); $color-status-public: var(--succes-700); $color-status-retracted: var(--notification-error-intense-default); $color-chevron-right: #999999; +$color-tab-active: $gray-535353; /* metrics */ $base-line-height: calc(26 / 16) * 1em; $input-line-height: calc(24 / 16) * 1em; $main-padding: 1.5rem; $default-gap: 8px; +$default-container-padding: 24px 16px; +$default-container-padding-nth-1: 0 16px 24px 16px; +$small-content-container: 808px; /* shadows */ -$shadow-xs: 0 1px 2px 0 rgba(16, 24, 40, 0.05); \ No newline at end of file +$shadow-xs: 0 1px 2px 0 rgba(16, 24, 40, 0.05); + +@mixin default-big-container { + width: 100%; + max-width: 1280px; +} + +@mixin default-small-container { + width: 100%; + max-width: $small-content-container; +} + +@mixin center { + margin-left: auto; + margin-right: auto; +} + +@mixin clear-padding { + padding: 0; +} diff --git a/assets/styles/admin/admin.scss b/assets/styles/admin/admin.scss index e8dc4045..691b5b70 100644 --- a/assets/styles/admin/admin.scss +++ b/assets/styles/admin/admin.scss @@ -1,23 +1,31 @@ +@import "admin-tailwind"; + //@import "@minvws/nl-rdo-rijksoverheid-ui-theme/scss/main"; -@import "admin-variables.scss"; -@import "admin-base.scss"; -@import "admin-badge.scss"; -@import "admin-signin.scss"; -@import "admin-decisions.scss"; +@import "admin-variables"; +@import "admin-base"; +@import "admin-components/all"; +@import "admin-badge"; +@import "admin-signin"; +@import "admin-search-form"; +@import "admin-decisions"; +@import "admin-decisions-new"; /* Manon overrides */ :root { - --footer-flex-direction: row; - --footer-min-height: 56px; - --link-text-color: #01689B; - --link-hover-text-color: #004161; - --link-text-decoration: underline; - --form-input-border-color: #ccc; - --icon-font-family: #{$font-icon}; - --button-icon-only-font-family: #{$font-icon}; - --succes-700: #027A48; - --notification-error-intense-default: #D52A1E; - --notification-warning-intense-default: #FFB71A; - --notification-explanation-intense-default: #007BC7; - --ro-basic-set-orange: #E17000; + --gray-2: #E6E6E6; + --ro-basic-set-orange: #E17000; + --footer-flex-direction: row; + --footer-min-height: 56px; + --link-text-color: #01689B; + --link-hover-text-color: #004161; + --link-text-decoration: underline; + --form-input-border-color: #ccc; + --icon-font-family: #{$font-icon}; + --icon-font-size: 1rem; + --succes-700: #027A48; + --notification-error-intense-default: #D52A1E; + --notification-warning-intense-default: #814081; + --notification-explanation-intense-default: #007BC7; + --color-step-default: var(--gray-2); + --color-step-active: var(--notification-explanation-intense-default); } diff --git a/assets/styles/components/all.scss b/assets/styles/components/all.scss index c873041b..5a918224 100644 --- a/assets/styles/components/all.scss +++ b/assets/styles/components/all.scss @@ -1,11 +1,15 @@ +@import "./colors.scss"; @import "./arrowed-link"; @import "./block-link"; -@import "./breadcrumbs"; @import "./button"; +@import "./content-container"; @import "./footer"; @import "./form-filters"; @import "./form"; @import "./gallery"; +@import "./grid"; +@import "./headings-variables"; +@import "./headings"; @import "./hero"; @import "./link"; @import "./list-densed"; @@ -16,11 +20,30 @@ @import "./logo"; @import "./main-navigation"; @import "./navigation-link"; +@import "./notification"; @import "./pagination"; +@import "./pipe-after"; @import "./pill"; +@import "./result-highlight"; @import "./reverse-underline"; @import "./search-form"; @import "./search-results"; @import "./section"; @import "./split-link"; @import "./table"; +@import "./tabs-variables"; +@import "./tabs"; +@import "./header"; +@import "./icons"; +@import "./link-visited"; +@import "./link-hover"; +@import "./link-focus"; +@import "./link"; +@import "./link-variables"; +@import "./breadcrumb-bar"; +@import "./button-base"; +@import "./form-base"; +@import "./form-select"; +@import "./header-navigation"; +@import "./header-navigation-link"; +@import "./form-select"; diff --git a/assets/styles/components/arrowed-link.scss b/assets/styles/components/arrowed-link.scss index 96d1dff7..5cf53f78 100644 --- a/assets/styles/components/arrowed-link.scss +++ b/assets/styles/components/arrowed-link.scss @@ -1,16 +1,26 @@ .arrowed-link { - padding-left: 1.25rem; position: relative; - &:before { - background: url('../../img/chevron-right.svg') no-repeat center center; - bottom: 0; - content: ''; - display: block; - left: 0; - padding: 0; - position: absolute; - top: 0; - width: 12px; + &:before, + &:hover:before { + content: var(--icon-chevron-right); + align-items: flex-start; + display: inline-flex; + height: 100%; + justify-self: center; + line-height: 1; + text-decoration-color: transparent; + font-size: 0.8rem; + transform: translateY(-2px); } -} \ No newline at end of file + + &.split-link { + &:before, + &:hover:before { + position: absolute; + left: -1.5rem; + top: 0.2rem; + transform: none; + } + } +} diff --git a/assets/styles/components/block-link.scss b/assets/styles/components/block-link.scss index 28de17d7..b5c2604e 100644 --- a/assets/styles/components/block-link.scss +++ b/assets/styles/components/block-link.scss @@ -1,39 +1,31 @@ -.block-link { +a.block-link { display: block; + color: var(--text-set-text-color); + text-decoration: none; - h2, h3 { - margin-bottom: .5rem; - text-decoration: none; + margin-bottom: 0.5rem; + color: var(--headings-text-color); + font-size: var(--h3-font-size); + font-weight: var(--text-set-strong-font-weight); } - &, &:focus, &:hover { color: var(--text-set-text-color); text-decoration: none; - h2 { - font-size: var(--h2-font-size, inherit); - font-weight: var(--text-set-strong-font-weight); - } - - h3 { - font-size: var(--h3-font-size, inherit); - font-weight: var(--text-set-strong-font-weight); - } - } - - &:focus, - &:hover { - - h2, h3 { + color: var(--headings-hover-text-color); text-decoration: underline; } } &:visited { color: var(--text-set-text-color); + + h3 { + color: var(--headings-visited-text-color, inherit); + } } -} \ No newline at end of file +} diff --git a/assets/styles/components/button.scss b/assets/styles/components/button.scss index e030ca14..69b0b8c3 100644 --- a/assets/styles/components/button.scss +++ b/assets/styles/components/button.scss @@ -1,12 +1,63 @@ .button.ghost:visited { - .icon::before { color: var(--button-ghost-text-color); } &:hover { + text-decoration: none; + .icon:before { - color: #fff; + color: white; + } + } +} + +.button.download-all { + background: transparent; + border-width: 3px; + font-weight: bold; + width: 210px; + + span { + color: var(--button-base-background-color); + + &:before { + filter: invert(16%) sepia(100%) saturate(3748%) hue-rotate(321deg) brightness(76%) contrast(112%); + } + } + + &:focus, + &:hover { + background: var(--button-base-background-color); + border-color: var(--button-base-background-color); + + span { + color: white; + + &:before { + filter: invert(100%) sepia(0%) saturate(7470%) hue-rotate(116deg) brightness(109%) contrast(109%); + } + } + } +} + +.button { + &.button-pink { + .svg-icon:before { + filter: invert(100%) sepia(0%) saturate(7500%) hue-rotate(106deg) brightness(106%) contrast(100%); + } + + &:hover { + background: transparent; + color: var(--ro-blue); + + span { + color: var(--ro-blue); + + &:before { + filter: invert(21%) sepia(97%) saturate(2139%) hue-rotate(182deg) brightness(92%) contrast(99%); + } + } } } } \ No newline at end of file diff --git a/assets/styles/components/form-filters.scss b/assets/styles/components/form-filters.scss index 1be770e3..d8dea363 100644 --- a/assets/styles/components/form-filters.scss +++ b/assets/styles/components/form-filters.scss @@ -1,22 +1,90 @@ .form-filters { background-color: transparent; + display: block; line-height: 1.25rem; padding: 0; + fieldset { + margin-bottom: 1rem; + } + legend { - margin-bottom: .25rem; + margin-bottom: 0.25rem; padding-left: 0; + width: 100%; + } + + input { + font-size: 1rem; } .checkbox { - margin: .3rem 0; + margin: 0.3rem 0; - input[type=checkbox] { - margin-top: 0; + input[type="checkbox"] { + border-color: #696969; + border-radius: 2px; + border-width: 1px; + margin-top: 0.125rem; } } label { font-size: 1rem; } -} \ No newline at end of file +} + +.toggle-filters-group-button { + padding: 0.5rem 0; + position: relative; + text-align: left; + width: 100%; + + &, + &:hover, + &:focus { + background: transparent; + border-color: #d7d6d6; + border-width: 0 0 1px; + color: var(--form-base-text-color); + } + + img { + margin-top: -0.25rem; + position: absolute; + right: 0; + transform: rotate(0deg); + } + + &.toggle-button--with-animation { + img { + transition: 0.25s ease-in-out transform; + } + } + + &.toggle-button--collapsed { + img { + transform: rotate(180deg); + } + } +} + +.toggle-filter-items-button { + font-size: 14px; + padding-left: 0; + padding-right: 0; + text-align: left; + + &, + &:hover, + &:focus { + background: transparent; + border: none; + color: var(--link-text-color); + } +} + +.filters-collapsible { + height: auto; + transition: 0.25s ease-in-out height; +} diff --git a/assets/styles/components/form.scss b/assets/styles/components/form.scss index b3e46816..fa92a173 100644 --- a/assets/styles/components/form.scss +++ b/assets/styles/components/form.scss @@ -1,3 +1,5 @@ :root { --form-base-background-color: transparent; + --form-fieldset-checkbox-height: 1rem; + --form-fieldset-checkbox-width: 1rem; } \ No newline at end of file diff --git a/assets/styles/components/gallery.scss b/assets/styles/components/gallery.scss index 6cab2b3d..e78f37c2 100644 --- a/assets/styles/components/gallery.scss +++ b/assets/styles/components/gallery.scss @@ -1,20 +1,52 @@ .gallery { background: #f3f3f3; - border-radius: .75rem; + display: block; padding: 1rem; } .gallery__container { display: flex; flex-direction: row; + gap: 1.5rem; overflow-x: auto; } -.gallery__item { +.gallery__image { display: block; + max-width: none; + margin-block-start: 2px; /* to fix focus */ + margin-block-end: 1rem; + width: auto; + + box-shadow: + 0px 4px 16px 0px rgba(0, 0, 0, 0.25), + 0px 2px 4px 0px rgba(0, 0, 0, 0.25); } -.gallery__image { +.gallery__image--no-thumbnail { + box-shadow: none; +} + +.skiplink-carousel-down, +.skiplink-carousel-up { display: block; - max-width: none; -} \ No newline at end of file + min-height: 0; + font-size: 0; + padding: 0; + border: 0; + max-width: 100%; + + &:focus { + height: auto; + font-size: var(--application-font-size); + min-height: var(--skip-to-content-min-height); + } +} + +#above-carousel:target .skiplink-carousel-up { + display: none; +} + +#below-carousel:target .skiplink-carousel-down { + display: none; +} diff --git a/assets/styles/components/link.scss b/assets/styles/components/link.scss index c67ce00f..be3f0665 100644 --- a/assets/styles/components/link.scss +++ b/assets/styles/components/link.scss @@ -1,26 +1,17 @@ -:root { - --link-focus-font-size: inherit; - --link-focus-font-weight: inherit; - --link-focus-line-height: inherit; +/*---------------------------------------------------------------*/ +/*--------------------------- link.scss -------------------------*/ +/*---------------------------------------------------------------*/ +@use "link-variables"; +@use "mixins/link"; +@use "mixins/outline"; - --link-hover-font-size: inherit; - --link-hover-font-weight: inherit; - --link-hover-line-height: inherit; - --link-hover-text-decoration: none; +a { + cursor: pointer; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; - --link-visited-line-height: inherit; + @include link.link("link-"); + @include link.link-elements-styling("link-"); + @include outline.outline("link-"); } - -h2>a { - color: var(--link-text-color); -} - -.link-green { - - &, - &:focus, - &:hover, - &:visited { - color: #39870c; - } -} \ No newline at end of file diff --git a/assets/styles/components/list-densed.scss b/assets/styles/components/list-densed.scss index 17ef92d0..6928ccb5 100644 --- a/assets/styles/components/list-densed.scss +++ b/assets/styles/components/list-densed.scss @@ -4,7 +4,9 @@ } li { - padding-bottom: 0; - padding-top: 0; + padding-bottom: .25rem; + padding-left: 1.5rem; + padding-top: .25rem; + line-height: 1.25; } } diff --git a/assets/styles/components/logo.scss b/assets/styles/components/logo.scss index 2f2c079f..8041a9dd 100644 --- a/assets/styles/components/logo.scss +++ b/assets/styles/components/logo.scss @@ -2,17 +2,23 @@ align-items: end; &, - &:hover { + &:hover, + &:focus, + &:active { line-height: 1; } + &:focus { + outline: var(--navigation-link-focus-outline); + outline-offset: -2px; + } + @media (width >=35rem) { max-width: 35rem; - } } .logo__text { display: block; - margin-bottom: .75rem; -} \ No newline at end of file + margin-bottom: 0.75rem; +} diff --git a/assets/styles/components/main-navigation.scss b/assets/styles/components/main-navigation.scss index 807ddb0b..d4736d5c 100644 --- a/assets/styles/components/main-navigation.scss +++ b/assets/styles/components/main-navigation.scss @@ -7,6 +7,9 @@ --breadcrumb-bar-link-active-text-color: #000; --breadcrumb-bar-link-focus-text-color: #000; --breadcrumb-bar-link-text-color: #000; + --breadcrumb-bar-content-wrapper-padding-left: 0; + --breadcrumb-bar-content-wrapper-padding-right: 0; + --breadcrumb-bar-icon: var(--icon-chevron-light-right); --div-content-wrapper-max-width: var(--content-max-width); --div-content-wrapper-padding-bottom: var(--content-padding-bottom); @@ -27,4 +30,26 @@ max-width: var(--div-content-wrapper-max-width); padding-left: var(--div-content-wrapper-padding-left); padding-right: var(--div-content-wrapper-padding-right); + + .one-third-two-thirds { + gap: inherit; + flex-direction: column; + + @media (max-width: $breakpoint-3) { + > div { + width: 100%; + &:first-of-type { + padding-top: 20px; + } + &:last-of-type { + padding-bottom: 20px; + } + } + } + + @media (min-width: $breakpoint-3) { + flex-direction: row; + gap: var(--layout-one-third-two-thirds-breakpoint-gap); + } + } } diff --git a/assets/styles/components/pagination.scss b/assets/styles/components/pagination.scss index 4412d63e..8890e7a1 100644 --- a/assets/styles/components/pagination.scss +++ b/assets/styles/components/pagination.scss @@ -1,13 +1,71 @@ +/*---------------------------------------------------------------*/ +/*---------------------- pagination.scss ------------------------*/ +/*---------------------------------------------------------------*/ + :root { + --pagination-link-border-width: 0 0 3px 0; --pagination-border-color: #e6e6e6; --pagination-border-width: 1px 0 0; } +%pagination-list-styling { + li { + span.svg-icon { + width: 30px; + height: 30px; + &:before { + display: inline-block; + content: ""; + width: 100%; + height: 100%; + } + } + } +} + +nav.pagination, +.pagination { + display: flex; + justify-content: var(--pagination-justify-content); + align-items: var(--pagination-align-items); + width: 100%; + border-width: var(--pagination-border-width); + border-style: var(--pagination-border-style); + border-color: var(--pagination-border-color); + padding-top: var(--pagination-padding-top); + gap: var(--pagination-gap); + + ul { + @extend %pagination-list-styling; + } + + .adjacent { + @extend %pagination-adjacent-styling; + } +} + +ul.pagination { + @extend %pagination-list-styling; +} + .pagination { ul { justify-content: center; padding-top: 1rem; width: 100%; + + span:hover { + background-color: transparent !important; + } + } + + @media (max-width: 42rem) { + li:nth-child(3), + li:nth-child(4), + li:nth-child(5), + li:nth-child(6) { + display: none; + } } .disabled:hover { @@ -20,7 +78,11 @@ &:hover { background-color: transparent; - text-decoration: underline; + // text-decoration: underline; } } -} \ No newline at end of file + + .active:not(:hover, :focus) { + outline: 0; + } +} diff --git a/assets/styles/components/pill.scss b/assets/styles/components/pill.scss index 0db26c91..f0567774 100644 --- a/assets/styles/components/pill.scss +++ b/assets/styles/components/pill.scss @@ -1,16 +1,25 @@ %pill { background-color: #f3f3f3; + border: none; color: #344054; display: inline-block; - padding: 0.25rem 1rem; + padding: 0.5rem 1rem; border-radius: 2rem; font-size: 1rem; text-decoration: none; } -.pill, -a.pill:focus, -a.pill:visited, -a.pill:hover { - @extend %pill; -} \ No newline at end of file +.pill { + + &, + &:hover, + &:focus, + &:visited { + @extend %pill; + } + + &:hover, + &:focus { + text-decoration: line-through; + } +} diff --git a/assets/styles/components/search-form.scss b/assets/styles/components/search-form.scss index 78bbdf18..e97e6cfd 100644 --- a/assets/styles/components/search-form.scss +++ b/assets/styles/components/search-form.scss @@ -2,7 +2,8 @@ form.search-form { flex-direction: row; margin: 0 auto; padding: 1rem 0; - z-index: 0; + position: relative; + z-index: 1; button { align-self: auto; @@ -40,6 +41,24 @@ form.search-form { position: static !important; } } + + .search-form__suggestions { + background-color: #fff; + border: 1px solid #eee; + padding: 0 1rem; + left: 0; + position: absolute; + right: 0; + top: 70px; + + &:empty { + display: none; + } + + .list-results { + padding-bottom: 0; + } + } } form.search-form--hero { @@ -53,4 +72,11 @@ form.search-form--hero { margin-top: -56px; padding: var(--form-base-padding-top) var(--form-base-padding-right); } -} \ No newline at end of file +} + +[role="search"].half-width { + margin-inline-start: auto; + margin-inline-end: auto; + width: 100%; + max-width: 30rem; +} diff --git a/assets/styles/components/split-link.scss b/assets/styles/components/split-link.scss index dc37228c..e882e78c 100644 --- a/assets/styles/components/split-link.scss +++ b/assets/styles/components/split-link.scss @@ -1,20 +1,26 @@ -.split-link__underline { - text-decoration: underline; -} - .split-link { - text-decoration: none; - @media (min-width: 42rem) { - align-items: baseline; - display: flex; + &, + &:hover, + &:focus, + &:visited { + text-decoration: none; + } - .truncate { - max-width: 80%; + &:hover, + &:focus { + .split-link__underline { + text-decoration: underline; } + } + @media (min-width: 42rem) { .split-link__suffix { - margin-left: 0.5rem; + margin-left: 0.25rem; } } -} \ No newline at end of file + + span { + hyphens: none; + } +} diff --git a/assets/styles/components/table.scss b/assets/styles/components/table.scss index 70c0bd8c..65a84c9a 100644 --- a/assets/styles/components/table.scss +++ b/assets/styles/components/table.scss @@ -16,7 +16,31 @@ table { } } - tbody th { + th:first-of-type { + padding-inline-start: 0; + } + + tbody th, + tbody td { vertical-align: top; + white-space: normal; + } + + tbody td.icon-type:has(.svg-icon) { + padding-top: 13px; } + + .th-date { + min-width: 25ch; + } +} + +.table-default__thead { + background: unset; + color: black; + border: 0 solid var(--table-cell-border-color); + border-width: 0 0 1px; + font-style: italic; + font-weight: 300; + vertical-align: top; } \ No newline at end of file diff --git a/assets/styles/helpers.scss b/assets/styles/helpers.scss index 4213865f..da1a60aa 100644 --- a/assets/styles/helpers.scss +++ b/assets/styles/helpers.scss @@ -20,21 +20,85 @@ a.ro-font-bold:hover { gap: 0; } -@media (min-width: 42rem) { - .ro-truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - .ro-width-full { width: 100%; } .js { - // .results in selector: js. .js\:visually-hidden + + // .results in selector: .js\:visually-hidden & &\:visually-hidden { @extend .visually-hidden; } + + & &\:hidden { + @extend .hidden; + } +} + +.no-js { + + // .results in selector: .js\:visually-hidden + & &\:hidden { + @extend .hidden; + } +} + +.width-delimiter { + max-width: 70ch; + -webkit-hyphens: none; + hyphens: none; + word-break: none; +} + +.nota-bene:is(dl) { + >div { + background: none !important; + display: block !important; + } + + :is(dd, dt) { + display: inline; + color: var(--grey-6); + } +} + +a[href^="mailto"], +.content-container a[href^="https://"] { + + &, + &:focus, + &:hover { + &::before { + box-sizing: border-box; + font-size: 0; + height: 24px; + width: 24px; + } + } + + &::before { + background: no-repeat left -5px; + background-size: contain; + margin-right: 4px; + } +} + +a[href^="mailto"]::before { + background-image: url("../svg/sendusmail.svg"); + content: "E-mail us \00a0 "; + + [lang="nl"] & { + content: "E-mail ons \00a0 "; + } +} + +.content-container a[href^="https://"]::before { + background-image: url("../svg/externallink.svg"); + background-position: left -4px; + content: " (external website) \00a0"; + + [lang="nl"] & { + content: "(externe website) \00a0 "; + } } diff --git a/assets/woopie.js b/assets/woopie.js index 0724c0ee..51b2f706 100644 --- a/assets/woopie.js +++ b/assets/woopie.js @@ -5,6 +5,5 @@ require('bootstrap/dist/js/bootstrap.bundle'); require('@fortawesome/fontawesome-free/css/all.min.css'); import "./alpine.js" // Alpine -import "./facet.js" // Faceted views -import "./carousel.js" // Carousel import "./dropzone.js" // Dropzone uploads +import "./inquiry.js" diff --git a/composer.json b/composer.json index 238d02cb..1e30af7d 100644 --- a/composer.json +++ b/composer.json @@ -7,19 +7,23 @@ "php": ">=8.1", "ext-ctype": "*", "ext-iconv": "*", - "ext-zip": "*", "ext-intl": "*", + "ext-zip": "*", + "aws/aws-sdk-php": "^3.279", "doctrine/annotations": "^2.0", - "doctrine/doctrine-bundle": "^2.9", + "doctrine/doctrine-bundle": "^2.10", "doctrine/doctrine-fixtures-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.14", "elasticsearch/elasticsearch": "^8.7", "endroid/qr-code": "^4.8", + "erichard/elasticsearch-query-builder": "^3.0@beta", "fakerphp/faker": "^1.21", + "indiehd/filename-sanitizer": "^0.1.0", "jaytaph/typearray": "^0.0", "knplabs/knp-paginator-bundle": "^6.2", "league/flysystem": "^3.0", + "league/flysystem-aws-s3-v3": "^3.15", "league/flysystem-bundle": "^3.1", "mhujer/breadcrumbs-bundle": "^1.5", "minvws/horsebattery": "^1.1", @@ -29,6 +33,7 @@ "phpoffice/phpspreadsheet": "^1.28", "phpstan/phpdoc-parser": "^1.20", "predis/predis": "^2.2", + "presta/sitemap-bundle": "^3.3", "scheb/2fa-backup-code": "^6.8", "scheb/2fa-bundle": "^6.8", "scheb/2fa-email": "^6.8", @@ -66,6 +71,7 @@ "symfony/webpack-encore-bundle": "^2.0", "symfony/yaml": "6.3.*", "twig/extra-bundle": "^2.12|^3.0", + "twig/intl-extra": "^3.7", "twig/string-extra": "^3.6", "twig/twig": "^2.12|^3.0" }, @@ -134,12 +140,13 @@ "psalm/plugin-symfony": "^5.0", "slevomat/coding-standard": "^8.11", "squizlabs/php_codesniffer": "^3.7", + "stefanocbt/phpdotenv-sync": "^1.2", "symfony/browser-kit": "6.3.*", "symfony/css-selector": "6.3.*", "symfony/debug-bundle": "6.3.*", "symfony/maker-bundle": "^1.0", "symfony/phpunit-bridge": "^6.3", "symfony/web-profiler-bundle": "6.3.*", - "vimeo/psalm": "^5.10" + "vimeo/psalm": "5.9" } } diff --git a/composer.lock b/composer.lock index a20ddffa..c18d306c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,157 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b5b68ed5284da7997a704aefb0edade0", + "content-hash": "8474b12f4b91e6e492cf4ecc104df214", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.2", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "2f1dc7b7eda080498be96a4a6d683a41583030e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/2f1dc7b7eda080498be96a4a6d683a41583030e9", + "reference": "2f1dc7b7eda080498be96a4a6d683a41583030e9", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.2" + }, + "time": "2023-07-20T16:49:55+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.280.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "d68b83b3bc39b70bf33e9b8b5166facbe3e4fe9b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d68b83b3bc39b70bf33e9b8b5166facbe3e4fe9b", + "reference": "d68b83b3bc39b70bf33e9b8b5166facbe3e4fe9b", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.0.4", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "guzzlehttp/promises": "^1.4.0 || ^2.0", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "mtdowling/jmespath.php": "^2.6", + "php": ">=7.2.5", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^1.10.22", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "nette/neon": "^2.3", + "paragonie/random_compat": ">= 2", + "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "psr/cache": "^1.0", + "psr/simple-cache": "^1.0", + "sebastian/comparator": "^1.2.3 || ^4.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.280.2" + }, + "time": "2023-09-01T18:06:10+00:00" + }, { "name": "bacon/bacon-qr-code", "version": "2.0.8", @@ -62,16 +211,16 @@ }, { "name": "dasprid/enum", - "version": "1.0.4", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/DASPRiD/Enum.git", - "reference": "8e6b6ea76eabbf19ea2bf5b67b98e1860474012f" + "reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8e6b6ea76eabbf19ea2bf5b67b98e1860474012f", - "reference": "8e6b6ea76eabbf19ea2bf5b67b98e1860474012f", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/6faf451159fb8ba4126b925ed2d78acfce0dc016", + "reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016", "shasum": "" }, "require": { @@ -106,9 +255,9 @@ ], "support": { "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.4" + "source": "https://github.com/DASPRiD/Enum/tree/1.0.5" }, - "time": "2023-03-01T18:44:03+00:00" + "time": "2023-08-25T16:18:39+00:00" }, { "name": "doctrine/annotations", @@ -281,16 +430,16 @@ }, { "name": "doctrine/collections", - "version": "2.1.2", + "version": "2.1.3", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "db8cda536a034337f7dd63febecc713d4957f9ee" + "reference": "3023e150f90a38843856147b58190aa8b46cc155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/db8cda536a034337f7dd63febecc713d4957f9ee", - "reference": "db8cda536a034337f7dd63febecc713d4957f9ee", + "url": "https://api.github.com/repos/doctrine/collections/zipball/3023e150f90a38843856147b58190aa8b46cc155", + "reference": "3023e150f90a38843856147b58190aa8b46cc155", "shasum": "" }, "require": { @@ -303,7 +452,7 @@ "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.0", "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.22" + "vimeo/psalm": "^5.11" }, "type": "library", "autoload": { @@ -347,7 +496,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.1.2" + "source": "https://github.com/doctrine/collections/tree/2.1.3" }, "funding": [ { @@ -363,7 +512,7 @@ "type": "tidelift" } ], - "time": "2022-12-27T23:41:38+00:00" + "time": "2023-07-06T15:15:36+00:00" }, { "name": "doctrine/common", @@ -458,16 +607,16 @@ }, { "name": "doctrine/data-fixtures", - "version": "1.6.6", + "version": "1.6.7", "source": { "type": "git", "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "4af35dadbfcf4b00abb2a217c4c8c8800cf5fcf4" + "reference": "ae4e845decbe177348fdbecd04331f4fb96aa301" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/4af35dadbfcf4b00abb2a217c4c8c8800cf5fcf4", - "reference": "4af35dadbfcf4b00abb2a217c4c8c8800cf5fcf4", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/ae4e845decbe177348fdbecd04331f4fb96aa301", + "reference": "ae4e845decbe177348fdbecd04331f4fb96aa301", "shasum": "" }, "require": { @@ -477,14 +626,14 @@ }, "conflict": { "doctrine/dbal": "<2.13", - "doctrine/orm": "<2.12", + "doctrine/orm": "<2.14", "doctrine/phpcr-odm": "<1.3.0" }, "require-dev": { "doctrine/coding-standard": "^11.0", "doctrine/dbal": "^2.13 || ^3.0", "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", - "doctrine/orm": "^2.12", + "doctrine/orm": "^2.14", "ext-sqlite3": "*", "phpstan/phpstan": "^1.5", "phpunit/phpunit": "^8.5 || ^9.5 || ^10.0", @@ -520,7 +669,7 @@ ], "support": { "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/1.6.6" + "source": "https://github.com/doctrine/data-fixtures/tree/1.6.7" }, "funding": [ { @@ -536,20 +685,20 @@ "type": "tidelift" } ], - "time": "2023-04-20T13:08:54+00:00" + "time": "2023-08-17T21:15:33+00:00" }, { "name": "doctrine/dbal", - "version": "3.6.5", + "version": "3.6.6", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "96d5a70fd91efdcec81fc46316efc5bf3da17ddf" + "reference": "63646ffd71d1676d2f747f871be31b7e921c7864" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/96d5a70fd91efdcec81fc46316efc5bf3da17ddf", - "reference": "96d5a70fd91efdcec81fc46316efc5bf3da17ddf", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63646ffd71d1676d2f747f871be31b7e921c7864", + "reference": "63646ffd71d1676d2f747f871be31b7e921c7864", "shasum": "" }, "require": { @@ -565,10 +714,11 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.10.21", + "phpstan/phpstan": "1.10.29", "phpstan/phpstan-strict-rules": "^1.5", "phpunit/phpunit": "9.6.9", "psalm/plugin-phpunit": "0.18.4", + "slevomat/coding-standard": "8.13.1", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^5.4|^6.0", "symfony/console": "^4.4|^5.4|^6.0", @@ -632,7 +782,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.6.5" + "source": "https://github.com/doctrine/dbal/tree/3.6.6" }, "funding": [ { @@ -648,7 +798,7 @@ "type": "tidelift" } ], - "time": "2023-07-17T09:15:50+00:00" + "time": "2023-08-17T05:38:17+00:00" }, { "name": "doctrine/deprecations", @@ -699,16 +849,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.10.1", + "version": "2.10.2", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "f9d59c90b6f525dfc2a2064a695cb56e0ab40311" + "reference": "f28b1f78de3a2938ff05cfe751233097624cc756" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/f9d59c90b6f525dfc2a2064a695cb56e0ab40311", - "reference": "f9d59c90b6f525dfc2a2064a695cb56e0ab40311", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/f28b1f78de3a2938ff05cfe751233097624cc756", + "reference": "f28b1f78de3a2938ff05cfe751233097624cc756", "shasum": "" }, "require": { @@ -795,7 +945,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.10.1" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.10.2" }, "funding": [ { @@ -811,7 +961,7 @@ "type": "tidelift" } ], - "time": "2023-06-28T07:47:41+00:00" + "time": "2023-08-06T09:31:40+00:00" }, { "name": "doctrine/doctrine-fixtures-bundle", @@ -1415,16 +1565,16 @@ }, { "name": "doctrine/orm", - "version": "2.15.4", + "version": "2.16.2", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "f7e4b61459692f9b747f40696e6bf72080390f2d" + "reference": "17500f56eaa930f5cd14d765bc2cd851c7d37cc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/f7e4b61459692f9b747f40696e6bf72080390f2d", - "reference": "f7e4b61459692f9b747f40696e6bf72080390f2d", + "url": "https://api.github.com/repos/doctrine/orm/zipball/17500f56eaa930f5cd14d765bc2cd851c7d37cc0", + "reference": "17500f56eaa930f5cd14d765bc2cd851c7d37cc0", "shasum": "" }, "require": { @@ -1453,14 +1603,14 @@ "doctrine/annotations": "^1.13 || ^2", "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", - "phpstan/phpstan": "~1.4.10 || 1.10.25", + "phpstan/phpstan": "~1.4.10 || 1.10.28", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0", - "vimeo/psalm": "4.30.0 || 5.13.1" + "vimeo/psalm": "4.30.0 || 5.14.1" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -1510,9 +1660,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.15.4" + "source": "https://github.com/doctrine/orm/tree/2.16.2" }, - "time": "2023-07-18T07:50:04+00:00" + "time": "2023-08-27T18:21:56+00:00" }, { "name": "doctrine/persistence", @@ -1784,16 +1934,16 @@ }, { "name": "elasticsearch/elasticsearch", - "version": "v8.8.2", + "version": "v8.9.0", "source": { "type": "git", "url": "git@github.com:elastic/elasticsearch-php.git", - "reference": "d249dcd6b6740ed39d89d7f16d59fadbefceef49" + "reference": "cbde0731140e1d6c4453fe8c41888fcdac426ddd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/d249dcd6b6740ed39d89d7f16d59fadbefceef49", - "reference": "d249dcd6b6740ed39d89d7f16d59fadbefceef49", + "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/cbde0731140e1d6c4453fe8c41888fcdac426ddd", + "reference": "cbde0731140e1d6c4453fe8c41888fcdac426ddd", "shasum": "" }, "require": { @@ -1801,7 +1951,7 @@ "guzzlehttp/guzzle": "^7.0", "php": "^7.4 || ^8.0", "psr/http-client": "^1.0", - "psr/http-message": "^1.0 || ^2.0", + "psr/http-message": "^1.1 || ^2.0", "psr/log": "^1|^2|^3" }, "require-dev": { @@ -1833,25 +1983,25 @@ "elasticsearch", "search" ], - "time": "2023-06-08T07:57:43+00:00" + "time": "2023-08-07T14:53:59+00:00" }, { "name": "endroid/qr-code", - "version": "4.8.2", + "version": "4.8.4", "source": { "type": "git", "url": "https://github.com/endroid/qr-code.git", - "reference": "2436c2333a3931c95e2b96eb82f16f53143d6bba" + "reference": "a122b85d4a5a3257d471257a43ac3e5676a27ffe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/endroid/qr-code/zipball/2436c2333a3931c95e2b96eb82f16f53143d6bba", - "reference": "2436c2333a3931c95e2b96eb82f16f53143d6bba", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/a122b85d4a5a3257d471257a43ac3e5676a27ffe", + "reference": "a122b85d4a5a3257d471257a43ac3e5676a27ffe", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^2.0.5", - "php": "^8.0" + "php": "^8.1" }, "conflict": { "khanamiryan/qrcode-detector-decoder": "^1.0.6" @@ -1900,7 +2050,7 @@ ], "support": { "issues": "https://github.com/endroid/qr-code/issues", - "source": "https://github.com/endroid/qr-code/tree/4.8.2" + "source": "https://github.com/endroid/qr-code/tree/4.8.4" }, "funding": [ { @@ -1908,7 +2058,60 @@ "type": "github" } ], - "time": "2023-03-30T18:46:02+00:00" + "time": "2023-08-28T18:12:07+00:00" + }, + { + "name": "erichard/elasticsearch-query-builder", + "version": "3.0.2-beta", + "source": { + "type": "git", + "url": "https://github.com/erichard/elasticsearch-query-builder.git", + "reference": "100a28e18a1af7749662871e4547d49e90f02909" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erichard/elasticsearch-query-builder/zipball/100a28e18a1af7749662871e4547d49e90f02909", + "reference": "100a28e18a1af7749662871e4547d49e90f02909", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4.10", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpunit/phpunit": "^9.5.19", + "rector/rector": "^0.12.17", + "symplify/easy-coding-standard": "^10.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Erichard\\ElasticQueryBuilder\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Erwan Richard", + "email": "erwan.richard@protonmail.com" + } + ], + "description": "Create elastic search query with a fluent interface", + "support": { + "issues": "https://github.com/erichard/elasticsearch-query-builder/issues", + "source": "https://github.com/erichard/elasticsearch-query-builder/tree/3.0.2-beta" + }, + "time": "2022-10-12T14:57:58+00:00" }, { "name": "ezyang/htmlpurifier", @@ -2041,22 +2244,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.7.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "fb7566caccf22d74d1ab270de3551f72a58399f5" + "reference": "1110f66a6530a40fe7aea0378fe608ee2b2248f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/fb7566caccf22d74d1ab270de3551f72a58399f5", - "reference": "fb7566caccf22d74d1ab270de3551f72a58399f5", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1110f66a6530a40fe7aea0378fe608ee2b2248f9", + "reference": "1110f66a6530a40fe7aea0378fe608ee2b2248f9", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0", - "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "guzzlehttp/promises": "^1.5.3 || ^2.0.1", + "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -2147,7 +2350,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.7.0" + "source": "https://github.com/guzzle/guzzle/tree/7.8.0" }, "funding": [ { @@ -2163,20 +2366,20 @@ "type": "tidelift" } ], - "time": "2023-05-21T14:04:53+00:00" + "time": "2023-08-27T10:20:53+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6" + "reference": "111166291a0f8130081195ac4556a5587d7f1b5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/3a494dc7dc1d7d12e511890177ae2d0e6c107da6", - "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6", + "url": "https://api.github.com/repos/guzzle/promises/zipball/111166291a0f8130081195ac4556a5587d7f1b5d", + "reference": "111166291a0f8130081195ac4556a5587d7f1b5d", "shasum": "" }, "require": { @@ -2230,7 +2433,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.0" + "source": "https://github.com/guzzle/promises/tree/2.0.1" }, "funding": [ { @@ -2246,20 +2449,20 @@ "type": "tidelift" } ], - "time": "2023-05-21T13:50:22+00:00" + "time": "2023-08-03T15:11:55+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.5.0", + "version": "2.6.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "b635f279edd83fc275f822a1188157ffea568ff6" + "reference": "be45764272e8873c72dbe3d2edcfdfcc3bc9f727" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6", - "reference": "b635f279edd83fc275f822a1188157ffea568ff6", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/be45764272e8873c72dbe3d2edcfdfcc3bc9f727", + "reference": "be45764272e8873c72dbe3d2edcfdfcc3bc9f727", "shasum": "" }, "require": { @@ -2346,7 +2549,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.5.0" + "source": "https://github.com/guzzle/psr7/tree/2.6.1" }, "funding": [ { @@ -2362,7 +2565,57 @@ "type": "tidelift" } ], - "time": "2023-04-17T16:11:26+00:00" + "time": "2023-08-27T10:13:57+00:00" + }, + { + "name": "indiehd/filename-sanitizer", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/indiehd/filename-sanitizer.git", + "reference": "e3e3dd75ba318792bda2d167b823133c5def773e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/indiehd/filename-sanitizer/zipball/e3e3dd75ba318792bda2d167b823133c5def773e", + "reference": "e3e3dd75ba318792bda2d167b823133c5def773e", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpro/grumphp": "^0.14.3", + "phpunit/phpunit": "^7" + }, + "type": "library", + "autoload": { + "psr-4": { + "IndieHD\\FilenameSanitizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "indieHD, LLC", + "email": "webmaster@indiehd.com", + "homepage": "https://indiehd.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/indiehd/filename-sanitizer/graphs/contributors" + } + ], + "description": "A lightweight library for sanitizing strings to be used as filenames.", + "support": { + "issues": "https://github.com/indiehd/filename-sanitizer/issues", + "source": "https://github.com/indiehd/filename-sanitizer/tree/master" + }, + "time": "2019-02-07T14:37:30+00:00" }, { "name": "jaytaph/typearray", @@ -2415,16 +2668,16 @@ }, { "name": "knplabs/knp-components", - "version": "v4.1.0", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/KnpLabs/knp-components.git", - "reference": "6b6efa81ee894e325744bf785d50dc962937b1f2" + "reference": "1f6560cc1247c8fe7ba75fe4f80f16ffcc9379a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/6b6efa81ee894e325744bf785d50dc962937b1f2", - "reference": "6b6efa81ee894e325744bf785d50dc962937b1f2", + "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/1f6560cc1247c8fe7ba75fe4f80f16ffcc9379a0", + "reference": "1f6560cc1247c8fe7ba75fe4f80f16ffcc9379a0", "shasum": "" }, "require": { @@ -2432,7 +2685,7 @@ "symfony/event-dispatcher-contracts": "^3.0" }, "conflict": { - "doctrine/dbal": "<2.10" + "doctrine/dbal": "<3.1" }, "require-dev": { "doctrine/mongodb-odm": "^2.4", @@ -2485,7 +2738,7 @@ } ], "description": "Knplabs component library", - "homepage": "http://github.com/KnpLabs/knp-components", + "homepage": "https://github.com/KnpLabs/knp-components", "keywords": [ "components", "knp", @@ -2495,9 +2748,9 @@ ], "support": { "issues": "https://github.com/KnpLabs/knp-components/issues", - "source": "https://github.com/KnpLabs/knp-components/tree/v4.1.0" + "source": "https://github.com/KnpLabs/knp-components/tree/v4.2.0" }, - "time": "2022-12-19T09:36:54+00:00" + "time": "2023-05-09T10:21:13+00:00" }, { "name": "knplabs/knp-paginator-bundle", @@ -2661,18 +2914,84 @@ ], "time": "2023-05-04T09:04:26+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.15.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "d8de61ee10b6a607e7996cff388c5a3a663e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d8de61ee10b6a607e7996cff388c5a3a663e8c8a", + "reference": "d8de61ee10b6a607e7996cff388c5a3a663e8c8a", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.220.0", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues", + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.15.0" + }, + "funding": [ + { + "url": "https://ecologi.com/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + } + ], + "time": "2023-05-02T20:02:14+00:00" + }, { "name": "league/flysystem-bundle", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-bundle.git", - "reference": "4b6e8095dbb9bed9971b4a5d8158cc6d8e720a26" + "reference": "c056bef0e8e0cdfb349e568d69e8337ce17ef6e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/4b6e8095dbb9bed9971b4a5d8158cc6d8e720a26", - "reference": "4b6e8095dbb9bed9971b4a5d8158cc6d8e720a26", + "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/c056bef0e8e0cdfb349e568d69e8337ce17ef6e1", + "reference": "c056bef0e8e0cdfb349e568d69e8337ce17ef6e1", "shasum": "" }, "require": { @@ -2717,9 +3036,9 @@ "description": "Symfony bundle integrating Flysystem into Symfony 5.4+ applications", "support": { "issues": "https://github.com/thephpleague/flysystem-bundle/issues", - "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.1.0" + "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.2.0" }, - "time": "2022-12-26T19:09:49+00:00" + "time": "2023-08-21T15:19:15+00:00" }, { "name": "league/flysystem-local", @@ -2783,26 +3102,26 @@ }, { "name": "league/mime-type-detection", - "version": "1.11.0", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "ff6248ea87a9f116e78edd6002e39e5128a0d4dd" + "reference": "a6dfb1194a2946fcdc1f38219445234f65b35c96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ff6248ea87a9f116e78edd6002e39e5128a0d4dd", - "reference": "ff6248ea87a9f116e78edd6002e39e5128a0d4dd", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/a6dfb1194a2946fcdc1f38219445234f65b35c96", + "reference": "a6dfb1194a2946fcdc1f38219445234f65b35c96", "shasum": "" }, "require": { "ext-fileinfo": "*", - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" }, "type": "library", "autoload": { @@ -2823,7 +3142,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.11.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.13.0" }, "funding": [ { @@ -2835,7 +3154,7 @@ "type": "tidelift" } ], - "time": "2022-04-17T13:12:02+00:00" + "time": "2023-08-05T12:09:49+00:00" }, { "name": "maennchen/zipstream-php", @@ -3240,27 +3559,97 @@ ], "time": "2023-06-21T08:46:11+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" + }, + "time": "2023-08-25T10:54:48+00:00" + }, { "name": "nesbot/carbon", - "version": "2.68.1", + "version": "2.69.0", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "4f991ed2a403c85efbc4f23eb4030063fdbe01da" + "reference": "4308217830e4ca445583a37d1bf4aff4153fa81c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/4f991ed2a403c85efbc4f23eb4030063fdbe01da", - "reference": "4f991ed2a403c85efbc4f23eb4030063fdbe01da", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/4308217830e4ca445583a37d1bf4aff4153fa81c", + "reference": "4308217830e4ca445583a37d1bf4aff4153fa81c", "shasum": "" }, "require": { "ext-json": "*", "php": "^7.1.8 || ^8.0", + "psr/clock": "^1.0", "symfony/polyfill-mbstring": "^1.0", "symfony/polyfill-php80": "^1.16", "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" }, + "provide": { + "psr/clock-implementation": "1.0" + }, "require-dev": { "doctrine/dbal": "^2.0 || ^3.1.4", "doctrine/orm": "^2.7", @@ -3340,7 +3729,7 @@ "type": "tidelift" } ], - "time": "2023-06-20T18:29:04+00:00" + "time": "2023-08-03T09:00:52+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -3671,16 +4060,16 @@ }, { "name": "php-http/discovery", - "version": "1.19.0", + "version": "1.19.1", "source": { "type": "git", "url": "https://github.com/php-http/discovery.git", - "reference": "1856a119a0b0ba8da8b5c33c080aa7af8fac25b4" + "reference": "57f3de01d32085fea20865f9b16fb0e69347c39e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/discovery/zipball/1856a119a0b0ba8da8b5c33c080aa7af8fac25b4", - "reference": "1856a119a0b0ba8da8b5c33c080aa7af8fac25b4", + "url": "https://api.github.com/repos/php-http/discovery/zipball/57f3de01d32085fea20865f9b16fb0e69347c39e", + "reference": "57f3de01d32085fea20865f9b16fb0e69347c39e", "shasum": "" }, "require": { @@ -3743,9 +4132,9 @@ ], "support": { "issues": "https://github.com/php-http/discovery/issues", - "source": "https://github.com/php-http/discovery/tree/1.19.0" + "source": "https://github.com/php-http/discovery/tree/1.19.1" }, - "time": "2023-06-19T08:45:36+00:00" + "time": "2023-07-11T07:02:26+00:00" }, { "name": "php-http/httplug", @@ -3973,16 +4362,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.7.2", + "version": "1.7.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "b2fe4d22a5426f38e014855322200b97b5362c0d" + "reference": "3219c6ee25c9ea71e3d9bbaf39c67c9ebd499419" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/b2fe4d22a5426f38e014855322200b97b5362c0d", - "reference": "b2fe4d22a5426f38e014855322200b97b5362c0d", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/3219c6ee25c9ea71e3d9bbaf39c67c9ebd499419", + "reference": "3219c6ee25c9ea71e3d9bbaf39c67c9ebd499419", "shasum": "" }, "require": { @@ -4025,9 +4414,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.7.2" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.7.3" }, - "time": "2023-05-30T18:13:47+00:00" + "time": "2023-08-12T11:01:26+00:00" }, { "name": "phpoffice/phpspreadsheet", @@ -4136,16 +4525,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.23.0", + "version": "1.23.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a2b24135c35852b348894320d47b3902a94bc494" + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a2b24135c35852b348894320d47b3902a94bc494", - "reference": "a2b24135c35852b348894320d47b3902a94bc494", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/846ae76eef31c6d7790fac9bc399ecee45160b26", + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26", "shasum": "" }, "require": { @@ -4177,22 +4566,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.1" }, - "time": "2023-07-23T22:17:56+00:00" + "time": "2023-08-03T16:32:59+00:00" }, { "name": "predis/predis", - "version": "v2.2.0", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "33b70b971a32b0d28b4f748b0547593dce316e0d" + "reference": "5f2b410a74afaff296a87a494e4c5488cf9fab57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/33b70b971a32b0d28b4f748b0547593dce316e0d", - "reference": "33b70b971a32b0d28b4f748b0547593dce316e0d", + "url": "https://api.github.com/repos/predis/predis/zipball/5f2b410a74afaff296a87a494e4c5488cf9fab57", + "reference": "5f2b410a74afaff296a87a494e4c5488cf9fab57", "shasum": "" }, "require": { @@ -4232,7 +4621,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v2.2.0" + "source": "https://github.com/predis/predis/tree/v2.2.1" }, "funding": [ { @@ -4240,7 +4629,75 @@ "type": "github" } ], - "time": "2023-06-14T10:37:31+00:00" + "time": "2023-08-15T23:01:46+00:00" + }, + { + "name": "presta/sitemap-bundle", + "version": "v3.3.1", + "source": { + "type": "git", + "url": "https://github.com/prestaconcept/PrestaSitemapBundle.git", + "reference": "29f14e0fb97ae4cb1b37dce7cee828871ad9be19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/prestaconcept/PrestaSitemapBundle/zipball/29f14e0fb97ae4cb1b37dce7cee828871ad9be19", + "reference": "29f14e0fb97ae4cb1b37dce7cee828871ad9be19", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": ">=7.1.3", + "symfony/console": "^4.4|^5.0|^6.0", + "symfony/framework-bundle": "^4.4|^5.0|^6.0" + }, + "require-dev": { + "doctrine/annotations": "^1.0", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^7.5|^8.0", + "sensio/framework-extra-bundle": "^5.5|^6.1", + "squizlabs/php_codesniffer": "^3.5", + "symfony/browser-kit": "^4.4|^5.0|^6.0", + "symfony/messenger": "^4.4|^5.0|^6.0", + "symfony/phpunit-bridge": "^4.4|^5.0|^6.0", + "symfony/yaml": "^4.4|^5.0|^6.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "3.x": "3.x-dev", + "2.x": "2.x-dev", + "1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Presta\\SitemapBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Prestaconcept", + "homepage": "http://www.prestaconcept.net/" + } + ], + "description": "A Symfony bundle that provides tools to build your application sitemap.", + "keywords": [ + "Sitemap", + "bundle", + "prestaconcept", + "symfony", + "xml" + ], + "support": { + "issues": "https://github.com/prestaconcept/PrestaSitemapBundle/issues", + "source": "https://github.com/prestaconcept/PrestaSitemapBundle/tree/v3.3.1" + }, + "time": "2023-04-24T07:19:47+00:00" }, { "name": "psr/cache", @@ -4805,7 +5262,7 @@ }, { "name": "scheb/2fa-backup-code", - "version": "v6.8.0", + "version": "v6.9.0", "source": { "type": "git", "url": "https://github.com/scheb/2fa-backup-code.git", @@ -4848,22 +5305,22 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-backup-code/tree/v6.8.0" + "source": "https://github.com/scheb/2fa-backup-code/tree/v6.9.0" }, "time": "2022-12-10T15:20:09+00:00" }, { "name": "scheb/2fa-bundle", - "version": "v6.8.0", + "version": "v6.9.0", "source": { "type": "git", "url": "https://github.com/scheb/2fa-bundle.git", - "reference": "4f8e9e87f90cf50c72b0857ea2b88453cf1d2446" + "reference": "98fee6bf6ce17514d8f3772d4c7f86e6f7595a85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/4f8e9e87f90cf50c72b0857ea2b88453cf1d2446", - "reference": "4f8e9e87f90cf50c72b0857ea2b88453cf1d2446", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/98fee6bf6ce17514d8f3772d4c7f86e6f7595a85", + "reference": "98fee6bf6ce17514d8f3772d4c7f86e6f7595a85", "shasum": "" }, "require": { @@ -4915,13 +5372,13 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-bundle/tree/v6.8.0" + "source": "https://github.com/scheb/2fa-bundle/tree/v6.9.0" }, - "time": "2023-01-26T18:47:22+00:00" + "time": "2023-08-05T11:13:58+00:00" }, { "name": "scheb/2fa-email", - "version": "v6.8.0", + "version": "v6.9.0", "source": { "type": "git", "url": "https://github.com/scheb/2fa-email.git", @@ -4964,13 +5421,13 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-email/tree/v6.8.0" + "source": "https://github.com/scheb/2fa-email/tree/v6.9.0" }, "time": "2022-12-10T15:20:09+00:00" }, { "name": "scheb/2fa-totp", - "version": "v6.8.0", + "version": "v6.9.0", "source": { "type": "git", "url": "https://github.com/scheb/2fa-totp.git", @@ -5015,7 +5472,7 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-totp/tree/v6.8.0" + "source": "https://github.com/scheb/2fa-totp/tree/v6.9.0" }, "time": "2022-12-10T15:20:09+00:00" }, @@ -5258,16 +5715,16 @@ }, { "name": "symfony/amqp-messenger", - "version": "v6.3.0", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/amqp-messenger.git", - "reference": "54a04a295f52e8c5567e11748b4d5c06724cadb5" + "reference": "0391200eb277d16d1a4ccad1aea05f0a23ee90ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/54a04a295f52e8c5567e11748b4d5c06724cadb5", - "reference": "54a04a295f52e8c5567e11748b4d5c06724cadb5", + "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/0391200eb277d16d1a4ccad1aea05f0a23ee90ac", + "reference": "0391200eb277d16d1a4ccad1aea05f0a23ee90ac", "shasum": "" }, "require": { @@ -5307,7 +5764,7 @@ "description": "Symfony AMQP extension Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/amqp-messenger/tree/v6.3.0" + "source": "https://github.com/symfony/amqp-messenger/tree/v6.3.4" }, "funding": [ { @@ -5323,7 +5780,7 @@ "type": "tidelift" } ], - "time": "2023-03-10T10:08:00+00:00" + "time": "2023-08-14T14:06:04+00:00" }, { "name": "symfony/asset", @@ -5396,16 +5853,16 @@ }, { "name": "symfony/cache", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "d176b97600860df13e851538c2df2ad88e9e5ca9" + "reference": "e60d00b4f633efa4c1ef54e77c12762d9073e7b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/d176b97600860df13e851538c2df2ad88e9e5ca9", - "reference": "d176b97600860df13e851538c2df2ad88e9e5ca9", + "url": "https://api.github.com/repos/symfony/cache/zipball/e60d00b4f633efa4c1ef54e77c12762d9073e7b3", + "reference": "e60d00b4f633efa4c1ef54e77c12762d9073e7b3", "shasum": "" }, "require": { @@ -5472,7 +5929,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.3.2" + "source": "https://github.com/symfony/cache/tree/v6.3.4" }, "funding": [ { @@ -5488,7 +5945,7 @@ "type": "tidelift" } ], - "time": "2023-07-27T16:19:14+00:00" + "time": "2023-08-05T09:10:27+00:00" }, { "name": "symfony/cache-contracts", @@ -5568,16 +6025,16 @@ }, { "name": "symfony/clock", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "2c72817f85cbdd0ae4e49643514a889004934296" + "reference": "a74086d3db70d0f06ffd84480daa556248706e98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/2c72817f85cbdd0ae4e49643514a889004934296", - "reference": "2c72817f85cbdd0ae4e49643514a889004934296", + "url": "https://api.github.com/repos/symfony/clock/zipball/a74086d3db70d0f06ffd84480daa556248706e98", + "reference": "a74086d3db70d0f06ffd84480daa556248706e98", "shasum": "" }, "require": { @@ -5621,7 +6078,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v6.3.1" + "source": "https://github.com/symfony/clock/tree/v6.3.4" }, "funding": [ { @@ -5637,7 +6094,7 @@ "type": "tidelift" } ], - "time": "2023-06-08T23:46:55+00:00" + "time": "2023-07-31T11:35:03+00:00" }, { "name": "symfony/config", @@ -5716,16 +6173,16 @@ }, { "name": "symfony/console", - "version": "v6.3.0", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8788808b07cf0bdd6e4b7fdd23d8ddb1470c83b7" + "reference": "eca495f2ee845130855ddf1cf18460c38966c8b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8788808b07cf0bdd6e4b7fdd23d8ddb1470c83b7", - "reference": "8788808b07cf0bdd6e4b7fdd23d8ddb1470c83b7", + "url": "https://api.github.com/repos/symfony/console/zipball/eca495f2ee845130855ddf1cf18460c38966c8b6", + "reference": "eca495f2ee845130855ddf1cf18460c38966c8b6", "shasum": "" }, "require": { @@ -5786,7 +6243,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.3.0" + "source": "https://github.com/symfony/console/tree/v6.3.4" }, "funding": [ { @@ -5802,20 +6259,20 @@ "type": "tidelift" } ], - "time": "2023-05-29T12:49:39+00:00" + "time": "2023-08-16T10:10:12+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "474cfbc46aba85a1ca11a27db684480d0db64ba7" + "reference": "68a5a9570806a087982f383f6109c5e925892a49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/474cfbc46aba85a1ca11a27db684480d0db64ba7", - "reference": "474cfbc46aba85a1ca11a27db684480d0db64ba7", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/68a5a9570806a087982f383f6109c5e925892a49", + "reference": "68a5a9570806a087982f383f6109c5e925892a49", "shasum": "" }, "require": { @@ -5867,7 +6324,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.3.2" + "source": "https://github.com/symfony/dependency-injection/tree/v6.3.4" }, "funding": [ { @@ -5883,7 +6340,7 @@ "type": "tidelift" } ], - "time": "2023-07-19T20:17:28+00:00" + "time": "2023-08-16T17:55:17+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5954,16 +6411,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "594263c7d2677022a16e4f39d20070463ba03888" + "reference": "589eeeb93669739ec1d8bd4593e4972d94e0981d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/594263c7d2677022a16e4f39d20070463ba03888", - "reference": "594263c7d2677022a16e4f39d20070463ba03888", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/589eeeb93669739ec1d8bd4593e4972d94e0981d", + "reference": "589eeeb93669739ec1d8bd4593e4972d94e0981d", "shasum": "" }, "require": { @@ -6044,7 +6501,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.3.1" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.3.4" }, "funding": [ { @@ -6060,7 +6517,7 @@ "type": "tidelift" } ], - "time": "2023-06-18T20:33:34+00:00" + "time": "2023-08-08T10:40:25+00:00" }, { "name": "symfony/doctrine-messenger", @@ -6567,16 +7024,16 @@ }, { "name": "symfony/finder", - "version": "v6.3.2", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "78ce4c29757d657d2b41a91c328923b9a0d6b43d" + "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/78ce4c29757d657d2b41a91c328923b9a0d6b43d", - "reference": "78ce4c29757d657d2b41a91c328923b9a0d6b43d", + "url": "https://api.github.com/repos/symfony/finder/zipball/9915db259f67d21eefee768c1abcf1cc61b1fc9e", + "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e", "shasum": "" }, "require": { @@ -6611,7 +7068,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.3.2" + "source": "https://github.com/symfony/finder/tree/v6.3.3" }, "funding": [ { @@ -6627,20 +7084,20 @@ "type": "tidelift" } ], - "time": "2023-07-13T14:29:38+00:00" + "time": "2023-07-31T08:31:44+00:00" }, { "name": "symfony/flex", - "version": "v2.3.1", + "version": "v2.3.3", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "3c9c3424efdafe33e0e3cfb5e87e50b34711fedf" + "reference": "9c402af768c6c9f8126a9ffa192ecf7c16581e35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/3c9c3424efdafe33e0e3cfb5e87e50b34711fedf", - "reference": "3c9c3424efdafe33e0e3cfb5e87e50b34711fedf", + "url": "https://api.github.com/repos/symfony/flex/zipball/9c402af768c6c9f8126a9ffa192ecf7c16581e35", + "reference": "9c402af768c6c9f8126a9ffa192ecf7c16581e35", "shasum": "" }, "require": { @@ -6676,7 +7133,7 @@ "description": "Composer plugin for Symfony", "support": { "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.3.1" + "source": "https://github.com/symfony/flex/tree/v2.3.3" }, "funding": [ { @@ -6692,20 +7149,20 @@ "type": "tidelift" } ], - "time": "2023-05-27T07:38:25+00:00" + "time": "2023-08-04T09:02:35+00:00" }, { "name": "symfony/form", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "59e7c5afef32b9ff735e83e5fc74d63044833a2b" + "reference": "afdadf511e08bc6d4752afb869ce084276aca4e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/59e7c5afef32b9ff735e83e5fc74d63044833a2b", - "reference": "59e7c5afef32b9ff735e83e5fc74d63044833a2b", + "url": "https://api.github.com/repos/symfony/form/zipball/afdadf511e08bc6d4752afb869ce084276aca4e2", + "reference": "afdadf511e08bc6d4752afb869ce084276aca4e2", "shasum": "" }, "require": { @@ -6773,7 +7230,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v6.3.0" + "source": "https://github.com/symfony/form/tree/v6.3.2" }, "funding": [ { @@ -6789,20 +7246,20 @@ "type": "tidelift" } ], - "time": "2023-05-25T13:09:35+00:00" + "time": "2023-07-26T17:39:03+00:00" }, { "name": "symfony/framework-bundle", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "930fe7ee25a928b9b3627d717873ddd171430a82" + "reference": "f822f54ff05cd88878910b4559f66c12176d952c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/930fe7ee25a928b9b3627d717873ddd171430a82", - "reference": "930fe7ee25a928b9b3627d717873ddd171430a82", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/f822f54ff05cd88878910b4559f66c12176d952c", + "reference": "f822f54ff05cd88878910b4559f66c12176d952c", "shasum": "" }, "require": { @@ -6917,7 +7374,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.3.2" + "source": "https://github.com/symfony/framework-bundle/tree/v6.3.4" }, "funding": [ { @@ -6933,20 +7390,20 @@ "type": "tidelift" } ], - "time": "2023-07-26T17:39:03+00:00" + "time": "2023-08-16T18:04:38+00:00" }, { "name": "symfony/http-client", - "version": "v6.3.1", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1c828a06aef2f5eeba42026dfc532d4fc5406123" + "reference": "15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1c828a06aef2f5eeba42026dfc532d4fc5406123", - "reference": "1c828a06aef2f5eeba42026dfc532d4fc5406123", + "url": "https://api.github.com/repos/symfony/http-client/zipball/15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00", + "reference": "15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00", "shasum": "" }, "require": { @@ -7009,7 +7466,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.3.1" + "source": "https://github.com/symfony/http-client/tree/v6.3.2" }, "funding": [ { @@ -7025,7 +7482,7 @@ "type": "tidelift" } ], - "time": "2023-06-24T11:51:27+00:00" + "time": "2023-07-05T08:41:27+00:00" }, { "name": "symfony/http-client-contracts", @@ -7107,16 +7564,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "43ed99d30f5f466ffa00bdac3f5f7aa9cd7617c3" + "reference": "cac1556fdfdf6719668181974104e6fcfa60e844" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/43ed99d30f5f466ffa00bdac3f5f7aa9cd7617c3", - "reference": "43ed99d30f5f466ffa00bdac3f5f7aa9cd7617c3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/cac1556fdfdf6719668181974104e6fcfa60e844", + "reference": "cac1556fdfdf6719668181974104e6fcfa60e844", "shasum": "" }, "require": { @@ -7164,7 +7621,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.3.2" + "source": "https://github.com/symfony/http-foundation/tree/v6.3.4" }, "funding": [ { @@ -7180,20 +7637,20 @@ "type": "tidelift" } ], - "time": "2023-07-23T21:58:39+00:00" + "time": "2023-08-22T08:20:46+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "51daa1e14a4b5cc7260c47d5a10a11ab32c88b63" + "reference": "36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/51daa1e14a4b5cc7260c47d5a10a11ab32c88b63", - "reference": "51daa1e14a4b5cc7260c47d5a10a11ab32c88b63", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb", + "reference": "36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb", "shasum": "" }, "require": { @@ -7202,7 +7659,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.3", "symfony/event-dispatcher": "^5.4|^6.0", - "symfony/http-foundation": "^6.2.7", + "symfony/http-foundation": "^6.3.4", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -7210,7 +7667,7 @@ "symfony/cache": "<5.4", "symfony/config": "<6.1", "symfony/console": "<5.4", - "symfony/dependency-injection": "<6.3", + "symfony/dependency-injection": "<6.3.4", "symfony/doctrine-bridge": "<5.4", "symfony/form": "<5.4", "symfony/http-client": "<5.4", @@ -7234,7 +7691,7 @@ "symfony/config": "^6.1", "symfony/console": "^5.4|^6.0", "symfony/css-selector": "^5.4|^6.0", - "symfony/dependency-injection": "^6.3", + "symfony/dependency-injection": "^6.3.4", "symfony/dom-crawler": "^5.4|^6.0", "symfony/expression-language": "^5.4|^6.0", "symfony/finder": "^5.4|^6.0", @@ -7277,7 +7734,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.3.2" + "source": "https://github.com/symfony/http-kernel/tree/v6.3.4" }, "funding": [ { @@ -7293,20 +7750,20 @@ "type": "tidelift" } ], - "time": "2023-07-30T09:04:05+00:00" + "time": "2023-08-26T13:54:49+00:00" }, { "name": "symfony/intl", - "version": "v6.3.1", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "fdf4aff85fff2cc681cc45936b6b2a52731acc23" + "reference": "1f8cb145c869ed089a8531c51a6a4b31ed0b3c69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/fdf4aff85fff2cc681cc45936b6b2a52731acc23", - "reference": "fdf4aff85fff2cc681cc45936b6b2a52731acc23", + "url": "https://api.github.com/repos/symfony/intl/zipball/1f8cb145c869ed089a8531c51a6a4b31ed0b3c69", + "reference": "1f8cb145c869ed089a8531c51a6a4b31ed0b3c69", "shasum": "" }, "require": { @@ -7314,7 +7771,8 @@ }, "require-dev": { "symfony/filesystem": "^5.4|^6.0", - "symfony/finder": "^5.4|^6.0" + "symfony/finder": "^5.4|^6.0", + "symfony/var-exporter": "^5.4|^6.0" }, "type": "library", "autoload": { @@ -7358,7 +7816,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v6.3.1" + "source": "https://github.com/symfony/intl/tree/v6.3.2" }, "funding": [ { @@ -7374,7 +7832,7 @@ "type": "tidelift" } ], - "time": "2023-06-21T12:08:28+00:00" + "time": "2023-07-20T07:43:09+00:00" }, { "name": "symfony/mailer", @@ -7458,16 +7916,16 @@ }, { "name": "symfony/messenger", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "e92ae9997f36e1189ff8251636adc21b8c9a6bea" + "reference": "bf460982736a4b99d11a3a90005ef438c3780df7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/e92ae9997f36e1189ff8251636adc21b8c9a6bea", - "reference": "e92ae9997f36e1189ff8251636adc21b8c9a6bea", + "url": "https://api.github.com/repos/symfony/messenger/zipball/bf460982736a4b99d11a3a90005ef438c3780df7", + "reference": "bf460982736a4b99d11a3a90005ef438c3780df7", "shasum": "" }, "require": { @@ -7486,6 +7944,7 @@ "psr/cache": "^1.0|^2.0|^3.0", "symfony/console": "^5.4|^6.0", "symfony/dependency-injection": "^5.4|^6.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher": "^5.4|^6.0", "symfony/http-kernel": "^5.4|^6.0", "symfony/process": "^5.4|^6.0", @@ -7523,7 +7982,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v6.3.1" + "source": "https://github.com/symfony/messenger/tree/v6.3.4" }, "funding": [ { @@ -7539,24 +7998,25 @@ "type": "tidelift" } ], - "time": "2023-06-21T12:08:28+00:00" + "time": "2023-08-14T14:06:04+00:00" }, { "name": "symfony/mime", - "version": "v6.3.0", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "7b5d2121858cd6efbed778abce9cfdd7ab1f62ad" + "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/7b5d2121858cd6efbed778abce9cfdd7ab1f62ad", - "reference": "7b5d2121858cd6efbed778abce9cfdd7ab1f62ad", + "url": "https://api.github.com/repos/symfony/mime/zipball/9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", + "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -7565,7 +8025,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/mailer": "<5.4", - "symfony/serializer": "<6.2" + "symfony/serializer": "<6.2.13|>=6.3,<6.3.2" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", @@ -7574,7 +8034,7 @@ "symfony/dependency-injection": "^5.4|^6.0", "symfony/property-access": "^5.4|^6.0", "symfony/property-info": "^5.4|^6.0", - "symfony/serializer": "^6.2" + "symfony/serializer": "~6.2.13|^6.3.2" }, "type": "library", "autoload": { @@ -7606,7 +8066,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.3.0" + "source": "https://github.com/symfony/mime/tree/v6.3.3" }, "funding": [ { @@ -7622,7 +8082,7 @@ "type": "tidelift" } ], - "time": "2023-04-28T15:57:00+00:00" + "time": "2023-07-31T07:08:24+00:00" }, { "name": "symfony/monolog-bridge", @@ -8002,16 +8462,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "511a08c03c1960e08a883f4cffcacd219b758354" + "reference": "875e90aeea2777b6f135677f618529449334a612" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/511a08c03c1960e08a883f4cffcacd219b758354", - "reference": "511a08c03c1960e08a883f4cffcacd219b758354", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612", + "reference": "875e90aeea2777b6f135677f618529449334a612", "shasum": "" }, "require": { @@ -8023,7 +8483,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8063,7 +8523,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.28.0" }, "funding": [ { @@ -8079,20 +8539,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "a3d9148e2c363588e05abbdd4ee4f971f0a5330c" + "reference": "e46b4da57951a16053cd751f63f4a24292788157" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/a3d9148e2c363588e05abbdd4ee4f971f0a5330c", - "reference": "a3d9148e2c363588e05abbdd4ee4f971f0a5330c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/e46b4da57951a16053cd751f63f4a24292788157", + "reference": "e46b4da57951a16053cd751f63f4a24292788157", "shasum": "" }, "require": { @@ -8104,7 +8564,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8150,7 +8610,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.28.0" }, "funding": [ { @@ -8166,20 +8626,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-03-21T17:27:24+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "639084e360537a19f9ee352433b84ce831f3d2da" + "reference": "ecaafce9f77234a6a449d29e49267ba10499116d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/639084e360537a19f9ee352433b84ce831f3d2da", - "reference": "639084e360537a19f9ee352433b84ce831f3d2da", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/ecaafce9f77234a6a449d29e49267ba10499116d", + "reference": "ecaafce9f77234a6a449d29e49267ba10499116d", "shasum": "" }, "require": { @@ -8193,7 +8653,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8237,7 +8697,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.28.0" }, "funding": [ { @@ -8253,20 +8713,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:30:37+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6" + "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/19bd1e4fcd5b91116f14d8533c57831ed00571b6", - "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", "shasum": "" }, "require": { @@ -8278,7 +8738,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8321,7 +8781,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0" }, "funding": [ { @@ -8337,20 +8797,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + "reference": "42292d99c55abe617799667f454222c54c60e229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", - "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", + "reference": "42292d99c55abe617799667f454222c54c60e229", "shasum": "" }, "require": { @@ -8365,7 +8825,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8404,7 +8864,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" }, "funding": [ { @@ -8420,20 +8880,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-07-28T09:04:16+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "508c652ba3ccf69f8c97f251534f229791b52a57" + "reference": "b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/508c652ba3ccf69f8c97f251534f229791b52a57", - "reference": "508c652ba3ccf69f8c97f251534f229791b52a57", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11", + "reference": "b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11", "shasum": "" }, "require": { @@ -8443,7 +8903,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8456,7 +8916,10 @@ ], "psr-4": { "Symfony\\Polyfill\\Php83\\": "" - } + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -8481,7 +8944,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.28.0" }, "funding": [ { @@ -8497,20 +8960,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-08-16T06:22:46+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "f3cf1a645c2734236ed1e2e671e273eeb3586166" + "reference": "9c44518a5aff8da565c8a55dbe85d2769e6f630e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/f3cf1a645c2734236ed1e2e671e273eeb3586166", - "reference": "f3cf1a645c2734236ed1e2e671e273eeb3586166", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/9c44518a5aff8da565c8a55dbe85d2769e6f630e", + "reference": "9c44518a5aff8da565c8a55dbe85d2769e6f630e", "shasum": "" }, "require": { @@ -8525,7 +8988,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -8563,7 +9026,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.28.0" }, "funding": [ { @@ -8579,20 +9042,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/process", - "version": "v6.3.0", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628" + "reference": "0b5c29118f2e980d455d2e34a5659f4579847c54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8741e3ed7fe2e91ec099e02446fb86667a0f1628", - "reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628", + "url": "https://api.github.com/repos/symfony/process/zipball/0b5c29118f2e980d455d2e34a5659f4579847c54", + "reference": "0b5c29118f2e980d455d2e34a5659f4579847c54", "shasum": "" }, "require": { @@ -8624,7 +9087,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.3.0" + "source": "https://github.com/symfony/process/tree/v6.3.4" }, "funding": [ { @@ -8640,7 +9103,7 @@ "type": "tidelift" } ], - "time": "2023-05-19T08:06:44+00:00" + "time": "2023-08-07T10:39:22+00:00" }, { "name": "symfony/property-access", @@ -8804,20 +9267,21 @@ }, { "name": "symfony/routing", - "version": "v6.3.2", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "9874c77e1746c7be68ae67e79433cbb202648a8d" + "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/9874c77e1746c7be68ae67e79433cbb202648a8d", - "reference": "9874c77e1746c7be68ae67e79433cbb202648a8d", + "url": "https://api.github.com/repos/symfony/routing/zipball/e7243039ab663822ff134fbc46099b5fdfa16f6a", + "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "doctrine/annotations": "<1.12", @@ -8866,7 +9330,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.3.2" + "source": "https://github.com/symfony/routing/tree/v6.3.3" }, "funding": [ { @@ -8882,20 +9346,20 @@ "type": "tidelift" } ], - "time": "2023-07-24T13:52:02+00:00" + "time": "2023-07-31T07:08:24+00:00" }, { "name": "symfony/runtime", - "version": "v6.3.1", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "8e83b5d8e0ace903e1a91dedfe08a84ed2a54b0d" + "reference": "d5c09493647a0c1a16e6c8da308098e840d1164f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/8e83b5d8e0ace903e1a91dedfe08a84ed2a54b0d", - "reference": "8e83b5d8e0ace903e1a91dedfe08a84ed2a54b0d", + "url": "https://api.github.com/repos/symfony/runtime/zipball/d5c09493647a0c1a16e6c8da308098e840d1164f", + "reference": "d5c09493647a0c1a16e6c8da308098e840d1164f", "shasum": "" }, "require": { @@ -8945,7 +9409,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v6.3.1" + "source": "https://github.com/symfony/runtime/tree/v6.3.2" }, "funding": [ { @@ -8961,20 +9425,20 @@ "type": "tidelift" } ], - "time": "2023-06-21T12:08:28+00:00" + "time": "2023-07-16T17:05:46+00:00" }, { "name": "symfony/security-bundle", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "f4fe79d7ebafd406e1a6f646839bfbbed641d8b2" + "reference": "31957477b289220a47880ead3727bf5cc059fa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f4fe79d7ebafd406e1a6f646839bfbbed641d8b2", - "reference": "f4fe79d7ebafd406e1a6f646839bfbbed641d8b2", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/31957477b289220a47880ead3727bf5cc059fa08", + "reference": "31957477b289220a47880ead3727bf5cc059fa08", "shasum": "" }, "require": { @@ -8984,6 +9448,7 @@ "symfony/clock": "^6.3", "symfony/config": "^6.1", "symfony/dependency-injection": "^6.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher": "^5.4|^6.0", "symfony/http-foundation": "^6.2", "symfony/http-kernel": "^6.2", @@ -9054,7 +9519,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.3.1" + "source": "https://github.com/symfony/security-bundle/tree/v6.3.4" }, "funding": [ { @@ -9070,24 +9535,25 @@ "type": "tidelift" } ], - "time": "2023-06-21T12:08:28+00:00" + "time": "2023-08-25T08:46:23+00:00" }, { "name": "symfony/security-core", - "version": "v6.3.0", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "9cb74232e978be1440d2bb7daf91eb40a9363890" + "reference": "b86ce012cc9a62a15ed43af5037eebc3e6de4d7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/9cb74232e978be1440d2bb7daf91eb40a9363890", - "reference": "9cb74232e978be1440d2bb7daf91eb40a9363890", + "url": "https://api.github.com/repos/symfony/security-core/zipball/b86ce012cc9a62a15ed43af5037eebc3e6de4d7f", + "reference": "b86ce012cc9a62a15ed43af5037eebc3e6de4d7f", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3", "symfony/password-hasher": "^5.4|^6.0", "symfony/service-contracts": "^2.5|^3" @@ -9138,7 +9604,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.3.0" + "source": "https://github.com/symfony/security-core/tree/v6.3.3" }, "funding": [ { @@ -9154,20 +9620,20 @@ "type": "tidelift" } ], - "time": "2023-04-28T15:57:00+00:00" + "time": "2023-07-31T07:08:24+00:00" }, { "name": "symfony/security-csrf", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "1f505c9060bde692eb37718c78a91d95d9abeeec" + "reference": "63d7b098c448cbddb46ea5eda33b68c1ece6eb5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/1f505c9060bde692eb37718c78a91d95d9abeeec", - "reference": "1f505c9060bde692eb37718c78a91d95d9abeeec", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/63d7b098c448cbddb46ea5eda33b68c1ece6eb5b", + "reference": "63d7b098c448cbddb46ea5eda33b68c1ece6eb5b", "shasum": "" }, "require": { @@ -9206,7 +9672,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v6.3.0" + "source": "https://github.com/symfony/security-csrf/tree/v6.3.2" }, "funding": [ { @@ -9222,20 +9688,20 @@ "type": "tidelift" } ], - "time": "2023-04-21T14:41:17+00:00" + "time": "2023-07-05T08:41:27+00:00" }, { "name": "symfony/security-http", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "36d2bdd09c33f63014dc65f164a77ff099d256c6" + "reference": "0afb37c1120c1c46219bdbd1dd912fb4d48eaf7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/36d2bdd09c33f63014dc65f164a77ff099d256c6", - "reference": "36d2bdd09c33f63014dc65f164a77ff099d256c6", + "url": "https://api.github.com/repos/symfony/security-http/zipball/0afb37c1120c1c46219bdbd1dd912fb4d48eaf7d", + "reference": "0afb37c1120c1c46219bdbd1dd912fb4d48eaf7d", "shasum": "" }, "require": { @@ -9293,7 +9759,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.3.1" + "source": "https://github.com/symfony/security-http/tree/v6.3.4" }, "funding": [ { @@ -9309,24 +9775,25 @@ "type": "tidelift" } ], - "time": "2023-06-18T15:50:12+00:00" + "time": "2023-08-25T19:43:09+00:00" }, { "name": "symfony/serializer", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "1d238ee3180bc047f8ab713bfb73848d553f4407" + "reference": "96d28a58d5a128bf77c54534b380eb7c92c8f846" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/1d238ee3180bc047f8ab713bfb73848d553f4407", - "reference": "1d238ee3180bc047f8ab713bfb73848d553f4407", + "url": "https://api.github.com/repos/symfony/serializer/zipball/96d28a58d5a128bf77c54534b380eb7c92c8f846", + "reference": "96d28a58d5a128bf77c54534b380eb7c92c8f846", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -9335,7 +9802,7 @@ "phpdocumentor/type-resolver": "<1.4.0", "symfony/dependency-injection": "<5.4", "symfony/property-access": "<5.4", - "symfony/property-info": "<5.4", + "symfony/property-info": "<5.4.24|>=6,<6.2.11", "symfony/uid": "<5.4", "symfony/yaml": "<5.4" }, @@ -9353,7 +9820,7 @@ "symfony/http-kernel": "^5.4|^6.0", "symfony/mime": "^5.4|^6.0", "symfony/property-access": "^5.4|^6.0", - "symfony/property-info": "^5.4|^6.0", + "symfony/property-info": "^5.4.24|^6.2.11", "symfony/uid": "^5.4|^6.0", "symfony/validator": "^5.4|^6.0", "symfony/var-dumper": "^5.4|^6.0", @@ -9386,7 +9853,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.3.1" + "source": "https://github.com/symfony/serializer/tree/v6.3.4" }, "funding": [ { @@ -9402,7 +9869,7 @@ "type": "tidelift" } ], - "time": "2023-06-21T19:54:33+00:00" + "time": "2023-08-24T14:35:28+00:00" }, { "name": "symfony/service-contracts", @@ -9636,20 +10103,21 @@ }, { "name": "symfony/translation", - "version": "v6.3.0", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "f72b2cba8f79dd9d536f534f76874b58ad37876f" + "reference": "3ed078c54bc98bbe4414e1e9b2d5e85ed5a5c8bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/f72b2cba8f79dd9d536f534f76874b58ad37876f", - "reference": "f72b2cba8f79dd9d536f534f76874b58ad37876f", + "url": "https://api.github.com/repos/symfony/translation/zipball/3ed078c54bc98bbe4414e1e9b2d5e85ed5a5c8bd", + "reference": "3ed078c54bc98bbe4414e1e9b2d5e85ed5a5c8bd", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/translation-contracts": "^2.5|^3.0" }, @@ -9710,7 +10178,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.3.0" + "source": "https://github.com/symfony/translation/tree/v6.3.3" }, "funding": [ { @@ -9726,7 +10194,7 @@ "type": "tidelift" } ], - "time": "2023-05-19T12:46:45+00:00" + "time": "2023-07-31T07:08:24+00:00" }, { "name": "symfony/translation-contracts", @@ -10075,16 +10543,16 @@ }, { "name": "symfony/validator", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "1b71f43c62ee867ab08195ba6039a1bc3e6654dc" + "reference": "0c8435154920b9bbe93bece675234c244cadf73b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/1b71f43c62ee867ab08195ba6039a1bc3e6654dc", - "reference": "1b71f43c62ee867ab08195ba6039a1bc3e6654dc", + "url": "https://api.github.com/repos/symfony/validator/zipball/0c8435154920b9bbe93bece675234c244cadf73b", + "reference": "0c8435154920b9bbe93bece675234c244cadf73b", "shasum": "" }, "require": { @@ -10151,7 +10619,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.3.1" + "source": "https://github.com/symfony/validator/tree/v6.3.4" }, "funding": [ { @@ -10167,24 +10635,25 @@ "type": "tidelift" } ], - "time": "2023-06-21T12:08:28+00:00" + "time": "2023-08-17T15:49:05+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "34e5ca671222670ae00749d1f554713021f8ef63" + "reference": "2027be14f8ae8eae999ceadebcda5b4909b81d45" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34e5ca671222670ae00749d1f554713021f8ef63", - "reference": "34e5ca671222670ae00749d1f554713021f8ef63", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2027be14f8ae8eae999ceadebcda5b4909b81d45", + "reference": "2027be14f8ae8eae999ceadebcda5b4909b81d45", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -10234,7 +10703,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.3.2" + "source": "https://github.com/symfony/var-dumper/tree/v6.3.4" }, "funding": [ { @@ -10250,20 +10719,20 @@ "type": "tidelift" } ], - "time": "2023-07-21T07:05:52+00:00" + "time": "2023-08-24T14:51:05+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.3.2", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "3400949782c0cb5b3e73aa64cfd71dde000beccc" + "reference": "df1f8aac5751871b83d30bf3e2c355770f8f0691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/3400949782c0cb5b3e73aa64cfd71dde000beccc", - "reference": "3400949782c0cb5b3e73aa64cfd71dde000beccc", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/df1f8aac5751871b83d30bf3e2c355770f8f0691", + "reference": "df1f8aac5751871b83d30bf3e2c355770f8f0691", "shasum": "" }, "require": { @@ -10308,7 +10777,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.3.2" + "source": "https://github.com/symfony/var-exporter/tree/v6.3.4" }, "funding": [ { @@ -10324,7 +10793,7 @@ "type": "tidelift" } ], - "time": "2023-07-26T17:39:03+00:00" + "time": "2023-08-16T18:14:47+00:00" }, { "name": "symfony/web-link", @@ -10482,20 +10951,21 @@ }, { "name": "symfony/yaml", - "version": "v6.3.0", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "a9a8337aa641ef2aa39c3e028f9107ec391e5927" + "reference": "e23292e8c07c85b971b44c1c4b87af52133e2add" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/a9a8337aa641ef2aa39c3e028f9107ec391e5927", - "reference": "a9a8337aa641ef2aa39c3e028f9107ec391e5927", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e23292e8c07c85b971b44c1c4b87af52133e2add", + "reference": "e23292e8c07c85b971b44c1c4b87af52133e2add", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -10530,62 +11000,127 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v6.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-31T07:08:24+00:00" + }, + { + "name": "twig/extra-bundle", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "f10baafe6eb0ecd615d52d5cbfb713a39f68e8f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/f10baafe6eb0ecd615d52d5cbfb713a39f68e8f3", + "reference": "f10baafe6eb0ecd615d52d5cbfb713a39f68e8f3", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "league/commonmark": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4|^6.3", + "twig/cache-extra": "^3.0", + "twig/cssinliner-extra": "^2.12|^3.0", + "twig/html-extra": "^2.12|^3.0", + "twig/inky-extra": "^2.12|^3.0", + "twig/intl-extra": "^2.12|^3.0", + "twig/markdown-extra": "^2.12|^3.0", + "twig/string-extra": "^2.12|^3.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Twig\\Extra\\TwigExtraBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Symfony bundle for extra Twig extensions", + "homepage": "https://twig.symfony.com", + "keywords": [ + "bundle", + "extra", + "twig" + ], "support": { - "source": "https://github.com/symfony/yaml/tree/v6.3.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.7.1" }, "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, { "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/twig/twig", "type": "tidelift" } ], - "time": "2023-04-28T13:28:14+00:00" + "time": "2023-07-29T15:34:56+00:00" }, { - "name": "twig/extra-bundle", - "version": "v3.6.1", + "name": "twig/intl-extra", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "802cc2dd46ec88285d6c7fa85c26ab7f2cd5bc49" + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "4f4fe572f635534649cc069e1dafe4a8ad63774d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/802cc2dd46ec88285d6c7fa85c26ab7f2cd5bc49", - "reference": "802cc2dd46ec88285d6c7fa85c26ab7f2cd5bc49", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/4f4fe572f635534649cc069e1dafe4a8ad63774d", + "reference": "4f4fe572f635534649cc069e1dafe4a8ad63774d", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/framework-bundle": "^4.4|^5.0|^6.0", - "symfony/twig-bundle": "^4.4|^5.0|^6.0", + "php": ">=7.1.3", + "symfony/intl": "^5.4|^6.0", "twig/twig": "^2.7|^3.0" }, "require-dev": { - "league/commonmark": "^1.0|^2.0", - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0", - "twig/cache-extra": "^3.0", - "twig/cssinliner-extra": "^2.12|^3.0", - "twig/html-extra": "^2.12|^3.0", - "twig/inky-extra": "^2.12|^3.0", - "twig/intl-extra": "^2.12|^3.0", - "twig/markdown-extra": "^2.12|^3.0", - "twig/string-extra": "^2.12|^3.0" + "symfony/phpunit-bridge": "^5.4|^6.3" }, - "type": "symfony-bundle", + "type": "library", "autoload": { "psr-4": { - "Twig\\Extra\\TwigExtraBundle\\": "" + "Twig\\Extra\\Intl\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -10603,15 +11138,14 @@ "role": "Lead Developer" } ], - "description": "A Symfony bundle for extra Twig extensions", + "description": "A Twig extension for Intl", "homepage": "https://twig.symfony.com", "keywords": [ - "bundle", - "extra", + "intl", "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.6.1" + "source": "https://github.com/twigphp/intl-extra/tree/v3.7.1" }, "funding": [ { @@ -10623,30 +11157,30 @@ "type": "tidelift" } ], - "time": "2023-05-06T11:11:46+00:00" + "time": "2023-07-29T15:34:56+00:00" }, { "name": "twig/string-extra", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/twigphp/string-extra.git", - "reference": "fab682645b3f8730fbdb7bf9ec8fe668d6f76638" + "reference": "7230d630a25e91cd91a2bd8e2f0e872962507eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/string-extra/zipball/fab682645b3f8730fbdb7bf9ec8fe668d6f76638", - "reference": "fab682645b3f8730fbdb7bf9ec8fe668d6f76638", + "url": "https://api.github.com/repos/twigphp/string-extra/zipball/7230d630a25e91cd91a2bd8e2f0e872962507eab", + "reference": "7230d630a25e91cd91a2bd8e2f0e872962507eab", "shasum": "" }, "require": { "php": ">=7.2.5", - "symfony/string": "^5.0|^6.0", + "symfony/string": "^5.4|^6.0", "symfony/translation-contracts": "^1.1|^2|^3", "twig/twig": "^2.7|^3.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^5.4|^6.3" }, "type": "library", "autoload": { @@ -10678,7 +11212,7 @@ "unicode" ], "support": { - "source": "https://github.com/twigphp/string-extra/tree/v3.6.0" + "source": "https://github.com/twigphp/string-extra/tree/v3.7.1" }, "funding": [ { @@ -10690,20 +11224,20 @@ "type": "tidelift" } ], - "time": "2023-02-09T06:45:16+00:00" + "time": "2023-07-29T15:34:56+00:00" }, { "name": "twig/twig", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "5cf942bbab3df42afa918caeba947f1b690af64b" + "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/5cf942bbab3df42afa918caeba947f1b690af64b", - "reference": "5cf942bbab3df42afa918caeba947f1b690af64b", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a0ce373a0ca3bf6c64b9e3e2124aca502ba39554", + "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554", "shasum": "" }, "require": { @@ -10713,7 +11247,7 @@ }, "require-dev": { "psr/container": "^1.0|^2.0", - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^5.4.9|^6.3" }, "type": "library", "autoload": { @@ -10749,7 +11283,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.7.0" + "source": "https://github.com/twigphp/Twig/tree/v3.7.1" }, "funding": [ { @@ -10761,7 +11295,7 @@ "type": "tidelift" } ], - "time": "2023-07-26T07:16:09+00:00" + "time": "2023-08-28T11:09:02+00:00" }, { "name": "webmozart/assert", @@ -10991,16 +11525,16 @@ }, { "name": "cmgmyr/phploc", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/cmgmyr/phploc.git", - "reference": "35e308033e02264a59cb1b56cc2abb1a22483ca8" + "reference": "e61d4729df46c5920ab61973bfa3f70f81a70b5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cmgmyr/phploc/zipball/35e308033e02264a59cb1b56cc2abb1a22483ca8", - "reference": "35e308033e02264a59cb1b56cc2abb1a22483ca8", + "url": "https://api.github.com/repos/cmgmyr/phploc/zipball/e61d4729df46c5920ab61973bfa3f70f81a70b5f", + "reference": "e61d4729df46c5920ab61973bfa3f70f81a70b5f", "shasum": "" }, "require": { @@ -11008,8 +11542,7 @@ "ext-json": "*", "php": "^7.4 || ^8.0", "phpunit/php-file-iterator": "^3.0|^4.0", - "sebastian/cli-parser": "^1.0|^2.0", - "sebastian/version": "^3.0|^4.0" + "sebastian/cli-parser": "^1.0|^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", @@ -11045,7 +11578,7 @@ "homepage": "https://github.com/cmgmyr/phploc", "support": { "issues": "https://github.com/cmgmyr/phploc/issues", - "source": "https://github.com/cmgmyr/phploc/tree/8.0.2" + "source": "https://github.com/cmgmyr/phploc/tree/8.0.3" }, "funding": [ { @@ -11053,7 +11586,7 @@ "type": "github" } ], - "time": "2023-03-19T10:37:20+00:00" + "time": "2023-08-05T16:49:39+00:00" }, { "name": "composer/pcre", @@ -11128,16 +11661,16 @@ }, { "name": "composer/semver", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9" + "reference": "35e8d0af4486141bc745f23a29cc2091eb624a32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/3953f23262f2bff1919fc82183ad9acb13ff62c9", - "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9", + "url": "https://api.github.com/repos/composer/semver/zipball/35e8d0af4486141bc745f23a29cc2091eb624a32", + "reference": "35e8d0af4486141bc745f23a29cc2091eb624a32", "shasum": "" }, "require": { @@ -11187,9 +11720,9 @@ "versioning" ], "support": { - "irc": "irc://irc.freenode.org/composer", + "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.3.2" + "source": "https://github.com/composer/semver/tree/3.4.0" }, "funding": [ { @@ -11205,7 +11738,7 @@ "type": "tidelift" } ], - "time": "2022-04-01T19:23:25+00:00" + "time": "2023-08-31T09:50:34+00:00" }, { "name": "composer/xdebug-handler", @@ -11595,26 +12128,24 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.21.1", + "version": "v3.25.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "229b55b3eae4729a8e2a321441ba40fcb3720b86" + "reference": "8e21d69801de6b5ecb0dbe0bcdf967b335b1260b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/229b55b3eae4729a8e2a321441ba40fcb3720b86", - "reference": "229b55b3eae4729a8e2a321441ba40fcb3720b86", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/8e21d69801de6b5ecb0dbe0bcdf967b335b1260b", + "reference": "8e21d69801de6b5ecb0dbe0bcdf967b335b1260b", "shasum": "" }, "require": { "composer/semver": "^3.3", "composer/xdebug-handler": "^3.0.3", - "doctrine/annotations": "^2", - "doctrine/lexer": "^2 || ^3", "ext-json": "*", "ext-tokenizer": "*", - "php": "^8.0.1", + "php": "^7.4 || ^8.0", "sebastian/diff": "^4.0 || ^5.0", "symfony/console": "^5.4 || ^6.0", "symfony/event-dispatcher": "^5.4 || ^6.0", @@ -11628,6 +12159,7 @@ "symfony/stopwatch": "^5.4 || ^6.0" }, "require-dev": { + "facile-it/paraunit": "^1.3 || ^2.0", "justinrainbow/json-schema": "^5.2", "keradus/cli-executor": "^2.0", "mikey179/vfsstream": "^1.6.11", @@ -11679,7 +12211,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.21.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.25.1" }, "funding": [ { @@ -11687,7 +12219,69 @@ "type": "github" } ], - "time": "2023-07-05T21:50:25+00:00" + "time": "2023-09-04T01:22:52+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "672eff8cf1d6fe1ef09ca0f89c4b287d6a3eb831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/672eff8cf1d6fe1ef09ca0f89c4b287d6a3eb831", + "reference": "672eff8cf1d6fe1ef09ca0f89c4b287d6a3eb831", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.32 || ^9.6.3 || ^10.0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2023-02-25T20:23:15+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -11953,16 +12547,16 @@ }, { "name": "masterminds/html5", - "version": "2.8.0", + "version": "2.8.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "3c5d5a56d56f48a1ca08a0670f0f80c1dad368f3" + "reference": "f47dcf3c70c584de14f21143c55d9939631bc6cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/3c5d5a56d56f48a1ca08a0670f0f80c1dad368f3", - "reference": "3c5d5a56d56f48a1ca08a0670f0f80c1dad368f3", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f47dcf3c70c584de14f21143c55d9939631bc6cf", + "reference": "f47dcf3c70c584de14f21143c55d9939631bc6cf", "shasum": "" }, "require": { @@ -12014,9 +12608,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.8.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.8.1" }, - "time": "2023-04-26T07:27:39+00:00" + "time": "2023-05-10T11:58:31+00:00" }, { "name": "mikey179/vfsstream", @@ -12071,31 +12665,31 @@ }, { "name": "mockery/mockery", - "version": "1.6.4", + "version": "1.6.6", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "d1413755e26fe56a63455f7753221c86cbb88f66" + "reference": "b8e0bb7d8c604046539c1115994632c74dcb361e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/d1413755e26fe56a63455f7753221c86cbb88f66", - "reference": "d1413755e26fe56a63455f7753221c86cbb88f66", + "url": "https://api.github.com/repos/mockery/mockery/zipball/b8e0bb7d8c604046539c1115994632c74dcb361e", + "reference": "b8e0bb7d8c604046539c1115994632c74dcb361e", "shasum": "" }, "require": { "hamcrest/hamcrest-php": "^2.0.1", "lib-pcre": ">=7.0", - "php": ">=7.4,<8.3" + "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.3", + "phpunit/phpunit": "^8.5 || ^9.6.10", "psalm/plugin-phpunit": "^0.18.4", "symplify/easy-coding-standard": "^11.5.0", - "vimeo/psalm": "^5.13.1" + "vimeo/psalm": "^4.30" }, "type": "library", "autoload": { @@ -12152,7 +12746,7 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2023-07-19T15:51:02+00:00" + "time": "2023-08-09T00:03:52+00:00" }, { "name": "myclabs/deep-copy", @@ -12266,16 +12860,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.16.0", + "version": "v4.17.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "19526a33fb561ef417e822e85f08a00db4059c17" + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/19526a33fb561ef417e822e85f08a00db4059c17", - "reference": "19526a33fb561ef417e822e85f08a00db4059c17", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", "shasum": "" }, "require": { @@ -12316,9 +12910,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.16.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" }, - "time": "2023-06-25T14:52:30+00:00" + "time": "2023-08-13T19:53:39+00:00" }, { "name": "nunomaduro/phpinsights", @@ -12802,18 +13396,93 @@ ], "time": "2022-09-10T08:44:15+00:00" }, + { + "name": "phpoption/phpoption", + "version": "1.9.1", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "dd3a383e599f49777d8b628dadbb90cae435b87e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/dd3a383e599f49777d8b628dadbb90cae435b87e", + "reference": "dd3a383e599f49777d8b628dadbb90cae435b87e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.32 || ^9.6.3 || ^10.0.12" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2023-02-25T19:38:58+00:00" + }, { "name": "phpstan/phpstan", - "version": "1.10.26", + "version": "1.10.32", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "5d660cbb7e1b89253a47147ae44044f49832351f" + "reference": "c47e47d3ab03137c0e121e77c4d2cb58672f6d44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/5d660cbb7e1b89253a47147ae44044f49832351f", - "reference": "5d660cbb7e1b89253a47147ae44044f49832351f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c47e47d3ab03137c0e121e77c4d2cb58672f6d44", + "reference": "c47e47d3ab03137c0e121e77c4d2cb58672f6d44", "shasum": "" }, "require": { @@ -12862,20 +13531,20 @@ "type": "tidelift" } ], - "time": "2023-07-19T12:44:37+00:00" + "time": "2023-08-24T21:54:50+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "1.3.40", + "version": "1.3.43", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "f741919a720af6f84249abc62befeb15eee7bc88" + "reference": "c5015035755ad2d5013bd6bf98ff423ca6150822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/f741919a720af6f84249abc62befeb15eee7bc88", - "reference": "f741919a720af6f84249abc62befeb15eee7bc88", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/c5015035755ad2d5013bd6bf98ff423ca6150822", + "reference": "c5015035755ad2d5013bd6bf98ff423ca6150822", "shasum": "" }, "require": { @@ -12903,8 +13572,8 @@ "nesbot/carbon": "^2.49", "nikic/php-parser": "^4.13.2", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-strict-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", "phpunit/phpunit": "^9.5.10", "ramsey/uuid-doctrine": "^1.5.0", "symfony/cache": "^4.4.35" @@ -12930,9 +13599,9 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.3.40" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.3.43" }, - "time": "2023-05-11T11:26:04+00:00" + "time": "2023-09-01T15:01:13+00:00" }, { "name": "phpstan/phpstan-mockery", @@ -13057,16 +13726,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "10.1.2", + "version": "10.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "db1497ec8dd382e82c962f7abbe0320e4882ee4e" + "reference": "cd59bb34756a16ca8253ce9b2909039c227fff71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/db1497ec8dd382e82c962f7abbe0320e4882ee4e", - "reference": "db1497ec8dd382e82c962f7abbe0320e4882ee4e", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/cd59bb34756a16ca8253ce9b2909039c227fff71", + "reference": "cd59bb34756a16ca8253ce9b2909039c227fff71", "shasum": "" }, "require": { @@ -13123,7 +13792,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.4" }, "funding": [ { @@ -13131,20 +13800,20 @@ "type": "github" } ], - "time": "2023-05-22T09:04:27+00:00" + "time": "2023-08-31T14:04:38+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "4.0.2", + "version": "4.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "5647d65443818959172645e7ed999217360654b6" + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/5647d65443818959172645e7ed999217360654b6", - "reference": "5647d65443818959172645e7ed999217360654b6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", "shasum": "" }, "require": { @@ -13184,7 +13853,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.0.2" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" }, "funding": [ { @@ -13192,7 +13861,7 @@ "type": "github" } ], - "time": "2023-05-07T09:13:23+00:00" + "time": "2023-08-31T06:24:48+00:00" }, { "name": "phpunit/php-invoker", @@ -13259,16 +13928,16 @@ }, { "name": "phpunit/php-text-template", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "9f3d3709577a527025f55bcf0f7ab8052c8bb37d" + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/9f3d3709577a527025f55bcf0f7ab8052c8bb37d", - "reference": "9f3d3709577a527025f55bcf0f7ab8052c8bb37d", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", "shasum": "" }, "require": { @@ -13306,7 +13975,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" }, "funding": [ { @@ -13314,7 +13984,7 @@ "type": "github" } ], - "time": "2023-02-03T06:56:46+00:00" + "time": "2023-08-31T14:07:24+00:00" }, { "name": "phpunit/php-timer", @@ -13377,16 +14047,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.2.6", + "version": "10.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "1c17815c129f133f3019cc18e8d0c8622e6d9bcd" + "reference": "0dafb1175c366dd274eaa9a625e914451506bcd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1c17815c129f133f3019cc18e8d0c8622e6d9bcd", - "reference": "1c17815c129f133f3019cc18e8d0c8622e6d9bcd", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0dafb1175c366dd274eaa9a625e914451506bcd1", + "reference": "0dafb1175c366dd274eaa9a625e914451506bcd1", "shasum": "" }, "require": { @@ -13411,7 +14081,7 @@ "sebastian/diff": "^5.0", "sebastian/environment": "^6.0", "sebastian/exporter": "^5.0", - "sebastian/global-state": "^6.0", + "sebastian/global-state": "^6.0.1", "sebastian/object-enumerator": "^5.0", "sebastian/recursion-context": "^5.0", "sebastian/type": "^4.0", @@ -13426,7 +14096,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.2-dev" + "dev-main": "10.3-dev" } }, "autoload": { @@ -13458,7 +14128,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.2.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.3.2" }, "funding": [ { @@ -13474,7 +14144,7 @@ "type": "tidelift" } ], - "time": "2023-07-17T12:08:28+00:00" + "time": "2023-08-15T05:34:23+00:00" }, { "name": "psalm/plugin-symfony", @@ -13710,16 +14380,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "72f01e6586e0caf6af81297897bd112eb7e9627c" + "reference": "2db5010a484d53ebf536087a70b4a5423c102372" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/72f01e6586e0caf6af81297897bd112eb7e9627c", - "reference": "72f01e6586e0caf6af81297897bd112eb7e9627c", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2db5010a484d53ebf536087a70b4a5423c102372", + "reference": "2db5010a484d53ebf536087a70b4a5423c102372", "shasum": "" }, "require": { @@ -13730,7 +14400,7 @@ "sebastian/exporter": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.3" }, "type": "library", "extra": { @@ -13774,7 +14444,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.1" }, "funding": [ { @@ -13782,20 +14453,20 @@ "type": "github" } ], - "time": "2023-02-03T07:07:16+00:00" + "time": "2023-08-14T13:18:12+00:00" }, { "name": "sebastian/complexity", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "e67d240970c9dc7ea7b2123a6d520e334dd61dc6" + "reference": "c70b73893e10757af9c6a48929fa6a333b56a97a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/e67d240970c9dc7ea7b2123a6d520e334dd61dc6", - "reference": "e67d240970c9dc7ea7b2123a6d520e334dd61dc6", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c70b73893e10757af9c6a48929fa6a333b56a97a", + "reference": "c70b73893e10757af9c6a48929fa6a333b56a97a", "shasum": "" }, "require": { @@ -13831,7 +14502,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.0.1" }, "funding": [ { @@ -13839,7 +14511,7 @@ "type": "github" } ], - "time": "2023-02-03T06:59:47+00:00" + "time": "2023-08-31T09:55:53+00:00" }, { "name": "sebastian/diff", @@ -14113,16 +14785,16 @@ }, { "name": "sebastian/lines-of-code", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "17c4d940ecafb3d15d2cf916f4108f664e28b130" + "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/17c4d940ecafb3d15d2cf916f4108f664e28b130", - "reference": "17c4d940ecafb3d15d2cf916f4108f664e28b130", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/649e40d279e243d985aa8fb6e74dd5bb28dc185d", + "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d", "shasum": "" }, "require": { @@ -14158,7 +14830,8 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.0" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.1" }, "funding": [ { @@ -14166,7 +14839,7 @@ "type": "github" } ], - "time": "2023-02-03T07:08:02+00:00" + "time": "2023-08-31T09:25:50+00:00" }, { "name": "sebastian/object-enumerator", @@ -14519,16 +15192,16 @@ }, { "name": "spatie/array-to-xml", - "version": "3.1.6", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/spatie/array-to-xml.git", - "reference": "e210b98957987c755372465be105d32113f339a4" + "reference": "f9ab39c808500c347d5a8b6b13310bd5221e39e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/e210b98957987c755372465be105d32113f339a4", - "reference": "e210b98957987c755372465be105d32113f339a4", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/f9ab39c808500c347d5a8b6b13310bd5221e39e7", + "reference": "f9ab39c808500c347d5a8b6b13310bd5221e39e7", "shasum": "" }, "require": { @@ -14566,7 +15239,7 @@ "xml" ], "support": { - "source": "https://github.com/spatie/array-to-xml/tree/3.1.6" + "source": "https://github.com/spatie/array-to-xml/tree/3.2.0" }, "funding": [ { @@ -14578,7 +15251,7 @@ "type": "github" } ], - "time": "2023-05-11T14:04:07+00:00" + "time": "2023-07-19T18:30:26+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -14637,6 +15310,58 @@ }, "time": "2023-02-22T23:07:41+00:00" }, + { + "name": "stefanocbt/phpdotenv-sync", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/StefanoCbt/phpdotenv-sync.git", + "reference": "bf0d3c3904411be8a73b80c3e53b1fa80d97239e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/StefanoCbt/phpdotenv-sync/zipball/bf0d3c3904411be8a73b80c3e53b1fa80d97239e", + "reference": "bf0d3c3904411be8a73b80c3e53b1fa80d97239e", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "vlucas/phpdotenv": "^5.2" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6", + "mockery/mockery": "^1.4", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "wapmorgan/php-deprecation-detector": "^2.0" + }, + "bin": [ + "bin/phpdotenvsync" + ], + "type": "library", + "autoload": { + "psr-4": { + "Stefanocbt\\PhpdotenvSync\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stefano Ciabatta", + "email": "stefanocbt.github@gmail.com" + } + ], + "description": "A package that makes sure that your .env file is in sync with your .env.example", + "support": { + "issues": "https://github.com/StefanoCbt/phpdotenv-sync/issues", + "source": "https://github.com/StefanoCbt/phpdotenv-sync/tree/1.2.0" + }, + "time": "2022-11-27T12:47:16+00:00" + }, { "name": "symfony/browser-kit", "version": "v6.3.2", @@ -14707,16 +15432,16 @@ }, { "name": "symfony/css-selector", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "88453e64cd86c5b60e8d2fb2c6f953bbc353ffbf" + "reference": "883d961421ab1709877c10ac99451632a3d6fa57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/88453e64cd86c5b60e8d2fb2c6f953bbc353ffbf", - "reference": "88453e64cd86c5b60e8d2fb2c6f953bbc353ffbf", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/883d961421ab1709877c10ac99451632a3d6fa57", + "reference": "883d961421ab1709877c10ac99451632a3d6fa57", "shasum": "" }, "require": { @@ -14752,7 +15477,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.3.0" + "source": "https://github.com/symfony/css-selector/tree/v6.3.2" }, "funding": [ { @@ -14768,20 +15493,20 @@ "type": "tidelift" } ], - "time": "2023-03-20T16:43:42+00:00" + "time": "2023-07-12T16:00:22+00:00" }, { "name": "symfony/debug-bundle", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/debug-bundle.git", - "reference": "02fe831f7cdd472c561116189bcc30d0759665e7" + "reference": "3f04a578e1a9f1d7da84a87b690c03123e5d8c31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/02fe831f7cdd472c561116189bcc30d0759665e7", - "reference": "02fe831f7cdd472c561116189bcc30d0759665e7", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/3f04a578e1a9f1d7da84a87b690c03123e5d8c31", + "reference": "3f04a578e1a9f1d7da84a87b690c03123e5d8c31", "shasum": "" }, "require": { @@ -14826,7 +15551,7 @@ "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug-bundle/tree/v6.3.0" + "source": "https://github.com/symfony/debug-bundle/tree/v6.3.2" }, "funding": [ { @@ -14842,20 +15567,20 @@ "type": "tidelift" } ], - "time": "2023-05-25T12:58:06+00:00" + "time": "2023-07-13T14:29:38+00:00" }, { "name": "symfony/dom-crawler", - "version": "v6.3.1", + "version": "v6.3.4", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "8aa333f41f05afc7fc285a976b58272fd90fc212" + "reference": "3fdd2a3d5fdc363b2e8dbf817f9726a4d013cbd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8aa333f41f05afc7fc285a976b58272fd90fc212", - "reference": "8aa333f41f05afc7fc285a976b58272fd90fc212", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/3fdd2a3d5fdc363b2e8dbf817f9726a4d013cbd1", + "reference": "3fdd2a3d5fdc363b2e8dbf817f9726a4d013cbd1", "shasum": "" }, "require": { @@ -14893,7 +15618,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.3.1" + "source": "https://github.com/symfony/dom-crawler/tree/v6.3.4" }, "funding": [ { @@ -14909,7 +15634,7 @@ "type": "tidelift" } ], - "time": "2023-06-05T15:30:22+00:00" + "time": "2023-08-01T07:43:40+00:00" }, { "name": "symfony/maker-bundle", @@ -15007,16 +15732,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v6.3.1", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "0b0bf59b0d9bd1422145a123a67fb12af546ef0d" + "reference": "e020e1efbd1b42cb670fcd7d19a25abbddba035d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/0b0bf59b0d9bd1422145a123a67fb12af546ef0d", - "reference": "0b0bf59b0d9bd1422145a123a67fb12af546ef0d", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/e020e1efbd1b42cb670fcd7d19a25abbddba035d", + "reference": "e020e1efbd1b42cb670fcd7d19a25abbddba035d", "shasum": "" }, "require": { @@ -15068,7 +15793,7 @@ "description": "Provides utilities for PHPUnit, especially user deprecation notices management", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v6.3.1" + "source": "https://github.com/symfony/phpunit-bridge/tree/v6.3.2" }, "funding": [ { @@ -15084,7 +15809,7 @@ "type": "tidelift" } ], - "time": "2023-06-23T13:25:16+00:00" + "time": "2023-07-12T16:00:22+00:00" }, { "name": "symfony/web-profiler-bundle", @@ -15219,16 +15944,16 @@ }, { "name": "vimeo/psalm", - "version": "5.13.1", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "086b94371304750d1c673315321a55d15fc59015" + "reference": "8b9ad1eb9e8b7d3101f949291da2b9f7767cd163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/086b94371304750d1c673315321a55d15fc59015", - "reference": "086b94371304750d1c673315321a55d15fc59015", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/8b9ad1eb9e8b7d3101f949291da2b9f7767cd163", + "reference": "8b9ad1eb9e8b7d3101f949291da2b9f7767cd163", "shasum": "" }, "require": { @@ -15319,23 +16044,109 @@ ], "support": { "issues": "https://github.com/vimeo/psalm/issues", - "source": "https://github.com/vimeo/psalm/tree/5.13.1" + "source": "https://github.com/vimeo/psalm/tree/5.9.0" + }, + "time": "2023-03-29T21:38:21+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.5.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "1a7ea2afc49c3ee6d87061f5a233e3a035d0eae7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/1a7ea2afc49c3ee6d87061f5a233e3a035d0eae7", + "reference": "1a7ea2afc49c3ee6d87061f5a233e3a035d0eae7", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.0.2", + "php": "^7.1.3 || ^8.0", + "phpoption/phpoption": "^1.8", + "symfony/polyfill-ctype": "^1.23", + "symfony/polyfill-mbstring": "^1.23.1", + "symfony/polyfill-php80": "^1.23.1" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "ext-filter": "*", + "phpunit/phpunit": "^7.5.20 || ^8.5.30 || ^9.5.25" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + }, + "branch-alias": { + "dev-master": "5.5-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.5.0" }, - "time": "2023-06-27T16:39:49+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2022-10-16T01:01:54+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "erichard/elasticsearch-query-builder": 10 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=8.1", "ext-ctype": "*", "ext-iconv": "*", - "ext-zip": "*", - "ext-intl": "*" + "ext-intl": "*", + "ext-zip": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.2.0" } diff --git a/config/bundles.php b/config/bundles.php index cc5ae002..67b4211c 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -18,4 +18,5 @@ League\FlysystemBundle\FlysystemBundle::class => ['all' => true], Snc\RedisBundle\SncRedisBundle::class => ['all' => true], WhiteOctober\BreadcrumbsBundle\WhiteOctoberBreadcrumbsBundle::class => ['all' => true], + Presta\SitemapBundle\PrestaSitemapBundle::class => ['all' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index f4ce11ea..43d8668c 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -19,6 +19,7 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App + report_fields_where_declared: true when@test: doctrine: diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index a46104f3..e15def54 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -1,6 +1,50 @@ flysystem: storages: document.storage: + adapter: 'lazy' + options: + source: 'document.storage.%env(STORAGE_DOCUMENT_ADAPTER)%' + thumbnail.storage: + adapter: 'lazy' + options: + source: 'thumbnail.storage.%env(STORAGE_THUMBNAIL_ADAPTER)%' + batch.storage: + adapter: 'lazy' + options: + source: 'batch.storage.%env(STORAGE_BATCH_ADAPTER)%' + + + # + # Minio storage definitions + # + document.storage.aws: + adapter: 'aws' + visibility: 'public' + directory_visibility: 'public' + options: + client: 'Aws\S3\S3Client' + bucket: '%env(STORAGE_MINIO_DOCUMENT_BUCKET)%' + + thumbnail.storage.aws: + adapter: 'aws' + visibility: 'public' + directory_visibility: 'public' + options: + client: 'Aws\S3\S3Client' + bucket: '%env(STORAGE_MINIO_THUMBNAIL_BUCKET)%' + + batch.storage.aws: + adapter: 'aws' + visibility: 'public' + directory_visibility: 'public' + options: + client: 'Aws\S3\S3Client' + bucket: '%env(STORAGE_MINIO_BATCH_BUCKET)%' + + # + # Local storage definitions + # + document.storage.local: adapter: 'local' visibility: 'public' directory_visibility: 'public' @@ -14,7 +58,7 @@ flysystem: public: 0o775 private: 0o700 - thumbnail.storage: + thumbnail.storage.local: adapter: 'local' visibility: 'public' directory_visibility: 'public' @@ -28,7 +72,7 @@ flysystem: public: 0o775 private: 0o700 - batch.storage: + batch.storage.local: adapter: 'local' visibility: 'public' directory_visibility: 'public' diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index d198eff9..ef4d22c6 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -9,7 +9,7 @@ framework: # Remove or comment this section to explicitly disable session support. session: enabled: true - handler_id: null + handler_id: App\Session\EncryptedSessionProxy cookie_secure: true cookie_samesite: lax storage_factory_id: session.storage.factory.native @@ -26,3 +26,9 @@ when@test: test: true session: storage_factory_id: session.storage.factory.mock_file + +when@dev: + framework: + session: + cookie_lifetime: 604800 + gc_maxlifetime: 604800 diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index aefb5249..5cab0d18 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -48,7 +48,8 @@ framework: App\Message\InitializeElasticRolloverMessage: high App\Message\SetElasticAliasMessage: high - App\Message\IngestDossiersMessage: ingestor # Ingests an audio file + App\Message\IngestDossiersMessage: ingestor # Ingests all dossiers + App\Message\IngestDossierMessage: ingestor # Ingests a single dossier App\Message\IngestAudioMessage: ingestor # Ingests an audio file App\Message\IngestPdfMessage: ingestor # Ingests a complete PDF document and fires a message for each page App\Message\IngestPdfPageMessage: ingestor # Ingests a single PDF page from a PDF document @@ -57,5 +58,6 @@ framework: App\Message\UpdateDossierMessage: esupdater # Updates the dossier in the elastic index App\Message\UpdateDepartmentMessage: esupdater # Updates the dossier in the elastic index App\Message\UpdateOfficialMessage: esupdater # Updates the dossier in the elastic index + App\Message\RemoveDossierMessage: esupdater # Removes a dossier in the elastic index App\Message\GenerateArchiveMessage: global # Generates a ZIP archive of the dossier diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index eaba0fa0..d163d999 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -45,22 +45,8 @@ when@prod: monolog: handlers: main: - type: fingers_crossed - action_level: error - handler: nested - excluded_http_codes: [404, 405] - buffer_size: 50 # How many messages should be saved? Prevent memory leaks - nested: - type: rotating_file - max_files: 3 - path: "%kernel.logs_dir%/%kernel.environment%.log" - level: debug + type: syslog + ident: woopie formatter: monolog.formatter.json - console: - type: console - process_psr_3_messages: false - channels: ["!event", "!doctrine"] - deprecation: - type: stream - channels: [deprecation] - path: php://stderr + level: info + channels: ["!request", "!doctrine", "!event", "!deprecation"] diff --git a/config/packages/security.yaml b/config/packages/security.yaml index bb3ccfd9..22d8961b 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -12,9 +12,8 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false - main: - pattern: ^/ - lazy: true + balie: + pattern: ^/balie form_login: provider: app_user_provider login_path: app_login @@ -27,22 +26,27 @@ security: two_factor: auth_form_path: 2fa_login check_path: 2fa_login_check + main: + pattern: ^/ + security: false role_hierarchy: - ROLE_ADMIN: ROLE_USER, ROLE_ADMIN_DOSSIERS, ROLE_ADMIN_USERS, ROLE_ADMIN_REQUESTS + ROLE_ADMIN_USERS: ROLE_BALIE + ROLE_ADMIN_REQUESTS: ROLE_BALIE + ROLE_ADMIN_DOSSIERS: ROLE_BALIE + ROLE_ADMIN: ROLE_USER, ROLE_ADMIN_DOSSIERS, ROLE_ADMIN_USERS, ROLE_ADMIN_REQUESTS ROLE_SUPER_ADMIN: ROLE_ADMIN # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - - { path: ^/login, roles: PUBLIC_ACCESS } - - { path: ^/logout, roles: PUBLIC_ACCESS } - - { path: ^/change-password, roles: ROLE_USER } - - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } - - { path: ^/2fa_check, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: ^/balie/login, roles: PUBLIC_ACCESS } + - { path: ^/balie/logout, roles: PUBLIC_ACCESS } + - { path: ^/balie/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: ^/balie/2fa_check, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } - { path: ^/balie/elastic, roles: ROLE_SUPER_ADMIN } - - { path: ^/balie, roles: ROLE_ADMIN } - - { path: ^/, roles: PUBLIC_ACCESS } + - { path: ^/balie/change-password, roles: ROLE_BALIE } + - { path: ^/balie, roles: ROLE_BALIE } when@test: security: diff --git a/config/packages/snc_redis.yaml b/config/packages/snc_redis.yaml index 2ac1a32b..3bb4c40c 100644 --- a/config/packages/snc_redis.yaml +++ b/config/packages/snc_redis.yaml @@ -4,13 +4,28 @@ snc_redis: type: predis alias: ingest dsn: '%env(REDIS_URL)%' + options: + parameters: + ssl_context: { + 'verify_peer': true, + 'allow_self_signed': false, + 'verify_peer_name': true, + 'cafile': '%env(REDIS_TLS_CAFILE)%', + 'local_cert': '%env(REDIS_TLS_LOCAL_CERT)%', + 'local_pk': '%env(REDIS_TLS_LOCAL_PK)%' + } -# Define your clients here. The example below connects to database 0 of the default Redis server. -# -# See https://github.com/snc/SncRedisBundle/blob/master/docs/README.md for instructions on -# how to configure the bundle. -# -# default: -# type: phpredis -# alias: default -# dsn: "%env(REDIS_URL)%" + session: + type: predis + alias: session + dsn: '%env(REDIS_URL)%' + options: + parameters: + ssl_context: { + 'verify_peer': true, + 'allow_self_signed': false, + 'verify_peer_name': true, + 'cafile': '%env(REDIS_TLS_CAFILE)%', + 'local_cert': '%env(REDIS_TLS_LOCAL_CERT)%', + 'local_pk': '%env(REDIS_TLS_LOCAL_PK)%' + } diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index c9b5c133..63cb4e91 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,8 +1,11 @@ twig: default_path: '%kernel.project_dir%/templates' form_themes: -# - 'bootstrap_5_horizontal_layout.html.twig' - - 'bootstrap_5_form_theme.html.twig' + - 'woo_form_theme.html.twig' + globals: + PUBLIC_BASE_URL: '%env(PUBLIC_BASE_URL)%' + PIWIK_ANALYTICS_ID: '%env(PIWIK_ANALYTICS_ID)%' + SITE_NAME: '%env(default:default_site_name:SITE_NAME)%' when@test: twig: diff --git a/config/routes.yaml b/config/routes.yaml index 4ec2df86..ac6fe83b 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -8,34 +8,71 @@ app_contact: path: /contact controller: App\Controller\LocalizedTemplateController defaults: - template: 'static/contact.{locale}.html.twig' + template: 'static/contact.html.twig' + options: + sitemap: + priority: 0.7 + changefreq: monthly app_privacy: path: /privacy controller: App\Controller\LocalizedTemplateController defaults: - template: 'static/privacy.{locale}.html.twig' + template: 'static/privacy.html.twig' + options: + sitemap: + priority: 0.7 + changefreq: monthly + section: legal -app_about: +app_about: &app_about path: /about controller: App\Controller\LocalizedTemplateController defaults: - template: 'static/about.{locale}.html.twig' + template: 'static/about.html.twig' + options: + sitemap: + priority: 0.7 + changefreq: monthly + section: legal + +app_about_dutch: + <<: *app_about + path: /over-deze-website app_copyright: path: /copyright controller: App\Controller\LocalizedTemplateController defaults: - template: 'static/copyright.{locale}.html.twig' + template: 'static/copyright.html.twig' + options: + sitemap: + priority: 0.7 + changefreq: monthly + section: legal app_cookies: path: /cookies controller: App\Controller\LocalizedTemplateController defaults: - template: 'static/cookies.{locale}.html.twig' + template: 'static/cookies.html.twig' + options: + sitemap: + priority: 0.7 + changefreq: monthly + section: legal -app_accessibility: +app_accessibility: &app_accessibility path: /accessibility controller: App\Controller\LocalizedTemplateController defaults: - template: 'static/accessibility.{locale}.html.twig' + template: 'static/accessibility.html.twig' + options: + sitemap: + priority: 0.7 + changefreq: monthly + section: legal + +app_accessibility_dutch: + <<: *app_accessibility + path: /toegankelijkheid diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml index 9a8ca667..9a733504 100644 --- a/config/routes/scheb_2fa.yaml +++ b/config/routes/scheb_2fa.yaml @@ -1,7 +1,7 @@ 2fa_login: - path: /2fa + path: /balie/2fa defaults: _controller: "scheb_two_factor.form_controller::form" 2fa_login_check: - path: /2fa_check + path: /balie/2fa_check diff --git a/config/services.yaml b/config/services.yaml index 36332ccb..d70963d3 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,8 @@ parameters: available_locales: ['en', 'nl'] + default_site_name: open.minvws.nl + services: _defaults: autowire: true # Automatically injects dependencies in your services. @@ -16,6 +18,7 @@ services: bind: $configPageLimit: '%configPageLimit%' + Psr\Log\LoggerInterface $logger: '@App\Service\Logging\EnrichedPsrLogger' App\: resource: '../src/' @@ -68,7 +71,7 @@ services: App\Service\Storage\DocumentStorageService: arguments: $storage: '@document.storage' - $isLocal: true + $isLocal: "@=env('STORAGE_DOCUMENT_ADAPTER')=='local' ? true : false" $documentRoot: '%document_path%' App\Service\Storage\ThumbnailStorageService: @@ -102,6 +105,9 @@ services: arguments: $redis: '@snc_redis.ingest' + App\Service\Worker\Pdf\Extractor\DecisionContentExtractor: + arguments: + $redis: '@snc_redis.ingest' App\Service\Worker\Pdf\Tools\Tika: arguments: @@ -140,4 +146,41 @@ services: App\Service\Logging\LoggingHelper: arguments: - - !tagged_iterator app.logging.type \ No newline at end of file + - !tagged_iterator app.logging.type + + App\Controller\StatsController: + arguments: + $redis: '@snc_redis.ingest' + $rabbitMqStatUrl: '%rabbitmq_stats_url%' + + + Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler: + arguments: + $redis: '@snc_redis.session' + + App\EventSubscriber\AppModeListener: + arguments: + $appMode: '%env(APP_MODE)%' + + App\Session\EncryptedSessionProxy: + arguments: + - '@Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler' + - '%env(APP_SECRET)%' + + App\Service\Logging\EnrichedPsrLogger: + arguments: + $logger: '@monolog.logger' + + Aws\S3\S3Client: + arguments: + - version: '2006-03-01' + region: '%env(STORAGE_MINIO_REGION)%' + endpoint: '%env(STORAGE_MINIO_ENDPOINT)%' + use_path_style_endpoint: true + credentials: + key: '%env(STORAGE_MINIO_ACCESS_KEY)%' + secret: '%env(STORAGE_MINIO_SECRET_KEY)%' + + App\EventSubscriber\SecurityHeaderSubscriber: + arguments: + $appMode: '%env(APP_MODE)%' diff --git a/docker/Dockerfile b/docker/Dockerfile index 8535eb59..af2d63a0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,7 +13,9 @@ RUN pecl install amqp && docker-php-ext-enable amqp RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer COPY woopie.conf /etc/apache2/sites-available/woopie.conf -RUN a2dissite 000-default && a2ensite woopie && service apache2 restart +COPY open-woopie.conf /etc/apache2/sites-available/open-woopie.conf +COPY balie-woopie.conf /etc/apache2/sites-available/balie-woopie.conf +RUN a2dissite 000-default && a2ensite woopie && a2ensite balie-woopie && a2ensite open-woopie && service apache2 restart # Install NodeJS RUN apt-get update -qq && \ diff --git a/docker/woopie.conf b/docker/woopie.conf index b8bc2116..db16b8e4 100644 --- a/docker/woopie.conf +++ b/docker/woopie.conf @@ -1,6 +1,8 @@ ServerName localhost + SetEnv APP_MODE both + DocumentRoot /var/www/html/public DirectoryIndex /index.php diff --git a/docs/install.md b/docs/install.md index a51e8519..182227ce 100644 --- a/docs/install.md +++ b/docs/install.md @@ -111,7 +111,7 @@ This will generate a password and 2fa token with which you can log into the webs ## Step 10: Browse to the site -When this is all done, you can goto the website at `http://localhost:8000/login`. You can log in with your +When this is all done, you can goto the website at `http://localhost:8000/balie/login`. You can log in with your generated credentials. See [usage](usage.md) for more information on how to use the application. diff --git a/docs/usage.md b/docs/usage.md index 337a6771..3e66bafb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -15,13 +15,13 @@ command: ``` Press ctrl-C when you feel you have enough documents. - + ### Create and upload real data On the Google drive (woo), you will find a folder called `Proof-of-concept-Woopie/test bestanden`. This folder contains a ZIP file with a number of PDF files and a few inventory XLS files. -You can create a dossier after logging in at `localhost:8000/balue/dossiers`. Fill in the dossier +You can create a dossier after logging in at `localhost:8000/balie/dossiers`. Fill in the dossier you want, and add a XLS file as inventory file. After this, you can upload the PDF files to the dossier. You can do this by clicking on the dossier diff --git a/package-lock.json b/package-lock.json index 32fc5708..6e78ed83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,28 +5,31 @@ "packages": { "": { "name": "woo", - "license": "UNLICENSED", + "license": "EUPL-1.2", "dependencies": { "@minvws/nl-rdo-rijksoverheid-ui-theme": "^0.0.14", "@popperjs/core": "^2.11.7", - "alpinejs": "^3.12.3", + "alpinejs": "^3.13.0", "dropzone": "^6.0.0-beta.2", "latte-carousel": "^1.6.1" }, "devDependencies": { "@babel/plugin-proposal-class-properties": "^7.18.6", - "@fortawesome/fontawesome-free": "^6.1.2", - "@hotwired/stimulus": "^3.1.0", + "@fortawesome/fontawesome-free": "^6.4.2", + "@hotwired/stimulus": "^3.2.2", "@symfony/stimulus-bridge": "^3.2.1", "@symfony/webpack-encore": "^4.4.0", - "autoprefixer": "^10.4.14", + "autoprefixer": "^10.4.15", "bootstrap": "^5.3.1", - "core-js": "^3.32.0", - "postcss": "^8.4.27", + "core-js": "^3.32.1", + "file-loader": "^6.2.0", + "lodash": "^4.17.21", + "markdownlint-cli2": "^0.9.2", + "postcss": "^8.4.29", "postcss-loader": "^7.3.3", "quoted-printable": "^1.0.1", - "regenerator-runtime": "^0.13.2", - "sass": "^1.64.1", + "regenerator-runtime": "^0.14.0", + "sass": "^1.66.1", "sass-loader": "^13.3.2", "tailwindcss": "^3.3.3", "webpack-notifier": "^1.6.0" @@ -1777,6 +1780,13 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, + "peer": true + }, "node_modules/@babel/template": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", @@ -1837,9 +1847,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.0.tgz", - "integrity": "sha512-0NyytTlPJwB/BF5LtRV8rrABDbe3TdTXqNB3PdZ+UUUZAEIrdOJdmABqKjt4AXwIoJNaRVVZEXxpNrqvE1GAYQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.2.tgz", + "integrity": "sha512-m5cPn3e2+FDCOgi1mz0RexTUvvQibBebOUlUlW0+YrMjDTPkiJ6VTKukA1GRsvRw+12KyJndNjj0O4AgTxm2Pg==", "dev": true, "hasInstallScript": true, "engines": { @@ -1847,9 +1857,9 @@ } }, "node_modules/@hotwired/stimulus": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.1.tgz", - "integrity": "sha512-HGlzDcf9vv/EQrMJ5ZG6VWNs8Z/xMN+1o2OhV1gKiSG6CqZt5MCBB1gRg5ILiN3U0jEAxuDTNPRfBcnZBDmupQ==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", + "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", "dev": true }, "node_modules/@hotwired/stimulus-webpack-helpers": { @@ -3016,9 +3026,9 @@ } }, "node_modules/alpinejs": { - "version": "3.12.3", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.12.3.tgz", - "integrity": "sha512-fLz2dfYQ3xCk7Ip8LiIpV2W+9brUyex2TAE7Z0BCvZdUDklJE+n+a8gCgLWzfZ0GzZNZu7HUP8Z0z6Xbm6fsSA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.0.tgz", + "integrity": "sha512-7FYR1Yz3evIjlJD1mZ3SYWSw+jlOmQGeQ1QiSufSQ6J84XMQFkzxm6OobiZ928SfqhGdoIp2SsABNsS4rXMMJw==", "dependencies": { "@vue/reactivity": "~3.1.1" } @@ -3132,9 +3142,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", + "version": "10.4.15", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", + "integrity": "sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew==", "dev": true, "funding": [ { @@ -3144,11 +3154,15 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001520", "fraction.js": "^4.2.0", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", @@ -3381,9 +3395,9 @@ } }, "node_modules/browserslist": { - "version": "4.21.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz", - "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", + "version": "4.21.10", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", + "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", "dev": true, "funding": [ { @@ -3400,9 +3414,9 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001503", - "electron-to-chromium": "^1.4.431", - "node-releases": "^2.0.12", + "caniuse-lite": "^1.0.30001517", + "electron-to-chromium": "^1.4.477", + "node-releases": "^2.0.13", "update-browserslist-db": "^1.0.11" }, "bin": { @@ -3483,9 +3497,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001508", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001508.tgz", - "integrity": "sha512-sdQZOJdmt3GJs1UMNpCCCyeuS2IEGLXnHyAo9yIO5JJDjbjoVRij4M1qep6P6gFpptD1PqIYgzM+gwJbOi92mw==", + "version": "1.0.30001520", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001520.tgz", + "integrity": "sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==", "dev": true, "funding": [ { @@ -3764,9 +3778,9 @@ "dev": true }, "node_modules/core-js": { - "version": "3.32.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.0.tgz", - "integrity": "sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww==", + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.1.tgz", + "integrity": "sha512-lqufgNn9NLnESg5mQeYsxQP5w7wrViSj0jr/kv6ECQiByzQkrn1MKvV0L3acttpDqfQrHLwr2KCMgX5b8X+lyQ==", "dev": true, "hasInstallScript": true, "funding": { @@ -4242,6 +4256,18 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4346,9 +4372,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.440", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.440.tgz", - "integrity": "sha512-r6dCgNpRhPwiWlxbHzZQ/d9swfPaEJGi8ekqRBwQYaR3WmA5VkqQfBWSDDjuJU1ntO+W9tHx8OHV/96Q8e0dVw==", + "version": "1.4.490", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz", + "integrity": "sha512-6s7NVJz+sATdYnIwhdshx/N/9O6rvMxmhVoDSDFdj6iA45gHR8EQje70+RYsF4GeB+k0IeNSBnP7yG9ZXJFr7A==", "dev": true }, "node_modules/emoji-regex": { @@ -4638,9 +4664,9 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -4698,6 +4724,26 @@ "node": ">=0.8.0" } }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -5214,6 +5260,15 @@ "postcss": "^8.1.0" } }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/immutable": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.0.tgz", @@ -5713,6 +5768,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "dev": true, + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -5799,12 +5863,118 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-it": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", + "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "~3.0.1", + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/markdownlint": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.30.0.tgz", + "integrity": "sha512-nInuFvI/rEzanAOArW5490Ez4EYpB5ODqVM0mcDYCPx9DKJWCQqCgejjiCvbSeE7sjbDscVtZmwr665qpF5xGA==", + "dev": true, + "dependencies": { + "markdown-it": "13.0.1", + "markdownlint-micromark": "0.1.7" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.9.2.tgz", + "integrity": "sha512-ndijEHIOikcs29W8068exHXlfkFviGFT/mPhREia7zSfQzHvTDkL2s+tWizvELjLHiKRO4KGTkkJyR3oeR8A5g==", + "dev": true, + "dependencies": { + "globby": "13.2.2", + "markdownlint": "0.30.0", + "markdownlint-cli2-formatter-default": "0.0.4", + "micromatch": "4.0.5", + "strip-json-comments": "5.0.1", + "yaml": "2.3.1" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2.js", + "markdownlint-cli2-config": "markdownlint-cli2-config.js", + "markdownlint-cli2-fix": "markdownlint-cli2-fix.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.4.tgz", + "integrity": "sha512-xm2rM0E+sWgjpPn1EesPXx5hIyrN2ddUnUwnbCsD/ONxYtw3PX6LydvdH6dciWAoFDpwzbHM1TO7uHfcMd6IYg==", + "dev": true, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/markdownlint-cli2/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-micromark": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.7.tgz", + "integrity": "sha512-BbRPTC72fl5vlSKv37v/xIENSRDYL/7X/XoFzZ740FGEbs9vZerLrIkFRY0rv7slQKxDczToYuMmqQFN61fi4Q==", + "dev": true, + "engines": { + "node": ">=16" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "dev": true }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6121,9 +6291,9 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz", - "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, "node_modules/normalize-path": { @@ -6542,9 +6712,9 @@ } }, "node_modules/postcss": { - "version": "8.4.27", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", - "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", + "version": "8.4.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz", + "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==", "dev": true, "funding": [ { @@ -7378,9 +7548,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", "dev": true }, "node_modules/regenerator-transform": { @@ -7614,9 +7784,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.64.1.tgz", - "integrity": "sha512-16rRACSOFEE8VN7SCgBu1MpYCyN7urj9At898tyzdXFhC+a+yOX5dXwAR7L8/IdPJ1NB8OYoXmD55DM30B2kEQ==", + "version": "1.66.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.66.1.tgz", + "integrity": "sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -7934,6 +8104,18 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -8062,6 +8244,18 @@ "node": ">=6" } }, + "node_modules/strip-json-comments": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz", + "integrity": "sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/style-loader": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", @@ -8351,15 +8545,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -8568,6 +8753,12 @@ "node": ">= 0.6" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -9156,6 +9347,15 @@ "dev": true, "peer": true }, + "node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/package.json b/package.json index d8e73d99..44ae7ebf 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,21 @@ }, "devDependencies": { "@babel/plugin-proposal-class-properties": "^7.18.6", - "@fortawesome/fontawesome-free": "^6.1.2", - "@hotwired/stimulus": "^3.1.0", + "@fortawesome/fontawesome-free": "^6.4.2", + "@hotwired/stimulus": "^3.2.2", "@symfony/stimulus-bridge": "^3.2.1", "@symfony/webpack-encore": "^4.4.0", - "autoprefixer": "^10.4.14", + "autoprefixer": "^10.4.15", "bootstrap": "^5.3.1", - "core-js": "^3.32.0", - "postcss": "^8.4.27", + "core-js": "^3.32.1", + "file-loader": "^6.2.0", + "lodash": "^4.17.21", + "markdownlint-cli2": "^0.9.2", + "postcss": "^8.4.29", "postcss-loader": "^7.3.3", "quoted-printable": "^1.0.1", - "regenerator-runtime": "^0.13.2", - "sass": "^1.64.1", + "regenerator-runtime": "^0.14.0", + "sass": "^1.66.1", "sass-loader": "^13.3.2", "tailwindcss": "^3.3.3", "webpack-notifier": "^1.6.0" @@ -28,12 +31,13 @@ "dev-server": "encore dev-server", "n": "encore dev", "watch": "encore dev --watch", - "build": "encore production --progress" + "build": "encore production --progress", + "mdlint": "markdownlint-cli2 '**/*.md' '!vendor/**' '!node_modules/**'" }, "dependencies": { "@minvws/nl-rdo-rijksoverheid-ui-theme": "^0.0.14", "@popperjs/core": "^2.11.7", - "alpinejs": "^3.12.3", + "alpinejs": "^3.13.0", "dropzone": "^6.0.0-beta.2", "latte-carousel": "^1.6.1" } diff --git a/public/placeholder.png b/public/placeholder.png index 21f7f2cd70ba23233c53ff16e2e9f946525e6aee..6f4af848d784b052f721748cb0e711cf325b71cd 100644 GIT binary patch literal 1579 zcmeAS@N?(olHy`uVBq!ia0vp^rx+L*WjWY@EMLtZ-9U=7z$3Dlfq`2Xgc%uT&5-~K zG8PB9J29*~C-V}>VM%xNb!1@J*w6hZk(Ggg^?;{~V@L(#+dGEYBAGJBKc;`V;KdQ4 z$q|=b+_aD3a`!K-HS0HgeAo)O8pdb5ESJ>I<_Zc42?;Q1G9}JcKglM%Y4&}! znKK*a^Ya+rG`#U-_y3u5Z)TpdeEji8=^2;WR=a-vI?lqgx zTK08g>_43Y_D?Nly#Lz1IKWTo=QX|a+y;TmPbc+0J$I73bW#V)xmCVb%)Bo9O;ox9pa%yY{P*50Uk`oSp!giSQ z@$;X4_wL=rZ-)*!?JR#E_pmJE*xEx6`1trvy?XWP;;vN_ZK}VmxwE%=yMax6-1Z~K zjvdQX3B7yo)-A0&d`>%AvqUC4ubZEnpFh8|{UOIwu@#q}D1Duiddu#Y>Xb&`*=oY2 z^H&<#EacIdDY5L-hZds+r<_5guy0@7uB2684j)#v3bTKD>;C=ze{Wx3 zUw^t=T)%Id`s>p^%T6`3^K;!b474&kUpl#J;zqlKMS7nL(hI-)OyN8G+jdgb_Yc?C z$MRngS)AK%~KpPzSK__y6Zf1`OKHyeMQtGg=v=H&6c)9)TK^8LHF za=GAi<3Ou|x#iz}wbxzs&}xsrE8xZ6R+H4Vd~1obhOERV%`ZSh^zYa#EO~HpvU;k< zb<3P&EA`K^E888ct*w{(e>D@^z6q#Qpx^~7<3a}q4FLfzmKLT_CRyRIpv_3@fR485 zl>b}?r@f5j`s24s-{&$o`@VeEvdAoRJJAP9^KZGi`QFW1e#*&mo))m|+jvQ@J6Gd= zzunhW(qCJXHXU%uP5ii|_tKpExBaFsfBR~E+0Vr@4E{v9J^gWbZN0yrr{7mdKI;Vst0QGZ3H2?qr literal 77589 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

8cC=T;7p$sQyU35A7NWR+U>n?F|X>}Uyk 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 ME@X{u!gwV97w^slq5uE@ diff --git a/ruleset.phpmd.xml b/ruleset.phpmd.xml index c2306016..6104c084 100644 --- a/ruleset.phpmd.xml +++ b/ruleset.phpmd.xml @@ -28,7 +28,7 @@ - + diff --git a/src/Citation.php b/src/Citation.php index e7aedf87..1f97f221 100644 --- a/src/Citation.php +++ b/src/Citation.php @@ -9,33 +9,80 @@ */ class Citation { + public const DUBBEL = 'dubbel'; + /** @var array|string[] */ - public static array $citations = [ + public static array $wooCitations = [ '5.1.1a' => 'Eenheid van de Kroon', '5.1.1b' => 'Veiligheid van de Staat', - '5.1.1c' => 'Vertrouwelijke bedrijfs- of fabricagegegevens', - '5.1.1d' => 'Persoonsgegevens', - '5.1.1e' => 'Persoonlijke identificatienummers', + '5.1.1c' => 'Vertrouwelijk verstrekte bedrijfs- of fabricagegegevens', + '5.1.1d' => 'Bijzondere persoonsgegevens', + '5.1.1e' => 'Nationale identificatienummers', '5.1.2a' => 'Internationale betrekkingen', - '5.1.2b' => 'Economische of financiële belangen van de overheid', + '5.1.2b' => 'Economische of financiële belangen van de Staat', '5.1.2c' => 'Opsporing en vervolging van strafbare feiten', - '5.1.2d' => 'Inspectie, controle en toezicht', + '5.1.2d' => 'Inspectie, controle en toezicht van bestuursorganen', '5.1.2e' => 'Eerbiediging van de persoonlijke levenssfeer', - '5.1.2f' => 'Concurrentiegevoelige bedrijfs- en fabricagegegevens', - '5.1.2g' => 'Bescherming van het milieu', - '5.1.2h' => 'Beveiliging van personen en bedrijven', - '5.1.2i' => 'Goed functioneren van de overheid', + '5.1.2f' => 'Bescherming van andere dan vertrouwelijk aan de overheid verstrekte concurrentiegevoelige bedrijfs- en fabricagegevens', + '5.1.2g' => 'De bescherming van het milieu waarop deze informatie betrekking heeft', + '5.1.2h' => 'De beveiliging van personen of bedrijven en het voorkomen van sabotage', + '5.1.2i' => 'Het goed functioneren van de staat, andere publiekrechtelijke lichamen of bestuursorganen', + '5.1.5' => 'Het voorkomen van onevenredige benadeling', + '5.2' => 'Persoonlijke beleidsopvattingen', + self::DUBBEL => 'Dubbel: inhoud is in een ander document al beoordeeld', + ]; + + /** @var array|string[] */ + public static array $wobCitations = [ + '10.1.a' => 'Eenheid van de Kroon', + '10.1.b' => 'Veiligheid van de Staat', + '10.1.c' => 'Vertrouwelijk verstrekte bedrijfs- en fabricagegegevens', + '10.1.d' => 'Bijzondere persoonsgegevens', + '10.2.a' => 'Internationale betrekkingen', + '10.2.b' => 'Economische of financiële belangen van de Staat', + '10.2.c' => 'Opsporing en vervolging van strafbare feiten', + '10.2.d' => 'Inspectie, controle en toezicht door bestuursorganen', + '10.2.e' => 'Eerbiediging van de persoonlijke levenssfeer', + '10.2.g' => 'Het voorkomen van onevenredige bevoordeling of benadeling', + '11.1' => 'Persoonlijke beleidsopvattingen', + self::DUBBEL => 'Dubbel: inhoud is in een ander document al beoordeeld', ]; + /** + * Converts a given citation to a human-readable classification. + */ public static function toClassification(string $citation): string + { + $canonical = str_replace(' ', '', $citation); + $canonical = strtolower($canonical); + + if (isset(self::$wobCitations[$canonical])) { + return self::$wobCitations[$canonical]; + } + + if (isset(self::$wooCitations[$canonical])) { + return self::$wooCitations[$canonical]; + } + + // Unknown citations get no classification intentionally + return ''; + } + + /** + * Returns citation type: woo, wob or unknown. + */ + public static function getCitationType(string $citation): string { $citation = str_replace(' ', '', $citation); $citation = strtolower($citation); - if (isset(self::$citations[$citation])) { - return self::$citations[$citation]; + if (isset(self::$wooCitations[$citation])) { + return 'woo'; + } + if (isset(self::$wobCitations[$citation])) { + return 'wob'; } - return "Onbekende reden ($citation)"; + return 'unknown'; } } diff --git a/src/Command/CleanSheet.php b/src/Command/CleanSheet.php index a274bf21..69a994d4 100644 --- a/src/Command/CleanSheet.php +++ b/src/Command/CleanSheet.php @@ -5,6 +5,7 @@ namespace App\Command; use App\Entity\Document; +use App\Entity\DocumentPrefix; use App\Entity\Dossier; use App\Entity\IngestLog; use App\Entity\Inquiry; @@ -44,6 +45,7 @@ protected function configure(): void ->setDefinition([ new InputOption('force', null, InputOption::VALUE_NONE, 'Force the operation without confirmation'), new InputOption('users', 'u', InputOption::VALUE_NONE, 'Reset users'), + new InputOption('prefixes', 'p', InputOption::VALUE_NONE, 'Reset prefixes'), new InputOption('index', 'i', InputOption::VALUE_REQUIRED, 'ES index name', 'woopie'), ]) ; @@ -80,6 +82,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->deleteAllEntities(User::class, $output); } + if ($input->getOption('prefixes')) { + $this->deleteAllEntities(DocumentPrefix::class, $output); + } + return 0; } diff --git a/src/Command/GenerateDocuments.php b/src/Command/GenerateDocuments.php index e4127188..83a046db 100644 --- a/src/Command/GenerateDocuments.php +++ b/src/Command/GenerateDocuments.php @@ -57,7 +57,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $dossierInquiries = $this->pickInquiries($inquiries, $i, 3); print "Creating dossier $i / $numberOfDossiers\n"; - $dossier = $this->createDossier('MINVWS-' . random_int(1000, 9999) . '-' . random_int(10000, 99999), $dossierInquiries); + $dossier = $this->createDossier('DOSSIER-' . random_int(1000, 9999) . '-' . random_int(10000, 99999), $dossierInquiries); $docCount = random_int(10, 100); for ($j = 0; $j !== $docCount; $j++) { diff --git a/src/Command/LoadFixture.php b/src/Command/LoadFixture.php index cd0746b7..04ccf83c 100644 --- a/src/Command/LoadFixture.php +++ b/src/Command/LoadFixture.php @@ -37,7 +37,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $file = strval($input->getArgument('file')); if (! file_exists($file)) { $output->writeln("File $file does not exist"); - $output->writeln("File $file does not exist"); return 1; } diff --git a/src/Command/UploadDocument.php b/src/Command/UploadDocument.php index 5fa509f7..ddd51171 100644 --- a/src/Command/UploadDocument.php +++ b/src/Command/UploadDocument.php @@ -5,7 +5,7 @@ namespace App\Command; use App\Entity\Dossier; -use App\Service\DocumentService; +use App\Service\FileProcessService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -14,14 +14,14 @@ class UploadDocument extends Command { - protected DocumentService $documentService; + protected FileProcessService $fileProcessService; protected EntityManagerInterface $doctrine; - public function __construct(DocumentService $documentService, EntityManagerInterface $doctrine) + public function __construct(FileProcessService $fileProcessService, EntityManagerInterface $doctrine) { parent::__construct(); - $this->documentService = $documentService; + $this->fileProcessService = $fileProcessService; $this->doctrine = $doctrine; } @@ -50,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $path = strval($input->getArgument('path')); $file = new \SplFileInfo($path); - $this->documentService->processDocument($file, $dossier, $path); + $this->fileProcessService->processFile($file, $dossier, $path); return 0; } diff --git a/src/Command/User/Create.php b/src/Command/User/Create.php index d6225b81..257c03f4 100644 --- a/src/Command/User/Create.php +++ b/src/Command/User/Create.php @@ -51,10 +51,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } elseif ($input->getOption('admin')) { $role = ['ROLE_ADMIN']; } else { - $role = ['ROLE_USER']; + $role = ['ROLE_USER', 'ROLE_BALIE']; } - ['plainPassword' => $plainPassword, 'user' => $user ] = $this->userService->createUser( + ['plainPassword' => $plainPassword, 'user' => $user] = $this->userService->createUser( strval($input->getArgument('name')), strval($input->getArgument('email')), $role, diff --git a/src/Command/Where.php b/src/Command/Where.php index 734b9bea..17a48a3a 100644 --- a/src/Command/Where.php +++ b/src/Command/Where.php @@ -76,8 +76,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } $output->writeln("Document : {$document->getId()}"); - $output->writeln("Filename : {$document->getFilename()}"); - $output->writeln("Path : {$document->getFilepath()}"); + $output->writeln("Filename : {$document->getFileInfo()->getName()}"); + $output->writeln("Path : {$document->getFileInfo()->getPath()}"); $output->writeln(''); } diff --git a/src/Controller/Admin/DepartmentController.php b/src/Controller/Admin/DepartmentController.php index 2ce95f8d..1d080ce0 100644 --- a/src/Controller/Admin/DepartmentController.php +++ b/src/Controller/Admin/DepartmentController.php @@ -67,7 +67,7 @@ public function create(Breadcrumbs $breadcrumbs, Request $request): Response $this->doctrine->persist($department); $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Department created')); + $this->addFlash('backend', ['success' => $this->translator->trans('Department created')]); return $this->redirectToRoute('app_admin_departments'); } @@ -93,7 +93,7 @@ public function createHead(Breadcrumbs $breadcrumbs, Request $request): Response $this->doctrine->persist($governmentOfficial); $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Official created')); + $this->addFlash('backend', ['success' => $this->translator->trans('Official created')]); return $this->redirectToRoute('app_admin_departments'); } @@ -118,7 +118,7 @@ public function modifyHead(Breadcrumbs $breadcrumbs, Request $request, Governmen $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Official modified')); + $this->addFlash('backend', ['success' => $this->translator->trans('Official modified')]); $this->messageBus->dispatch(new UpdateOfficialMessage($oldHead, $head)); @@ -146,7 +146,7 @@ public function modify(Breadcrumbs $breadcrumbs, Request $request, Department $d $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Department modified')); + $this->addFlash('backend', ['success' => $this->translator->trans('Department modified')]); $this->messageBus->dispatch(new UpdateDepartmentMessage($oldDepartment, $department)); diff --git a/src/Controller/Admin/Dossier/DocumentController.php b/src/Controller/Admin/Dossier/DocumentController.php index e49ea20f..690dcf00 100644 --- a/src/Controller/Admin/Dossier/DocumentController.php +++ b/src/Controller/Admin/Dossier/DocumentController.php @@ -6,8 +6,10 @@ use App\Entity\Document; use App\Entity\Dossier; +use App\Entity\WithdrawReason; use App\Form\Document\IngestFormType; use App\Form\Document\RemoveFormType; +use App\Form\Document\WithdrawFormType; use App\Form\Dossier\DocumentUploadType; use App\Service\DocumentService; use App\Service\FileUploader; @@ -27,21 +29,12 @@ */ class DocumentController extends AbstractController { - protected DocumentService $documentService; - protected IngestService $ingester; - protected FileUploader $fileUploader; - protected TranslatorInterface $translator; - public function __construct( - DocumentService $documentService, - IngestService $ingester, - FileUploader $fileUploader, - TranslatorInterface $translator + private readonly DocumentService $documentService, + private readonly IngestService $ingester, + private readonly FileUploader $fileUploader, + private readonly TranslatorInterface $translator ) { - $this->documentService = $documentService; - $this->ingester = $ingester; - $this->fileUploader = $fileUploader; - $this->translator = $translator; } #[Route('/balie/dossier/{dossierId}/documents', name: 'app_admin_dossier_documents_edit', methods: ['GET'])] @@ -80,6 +73,7 @@ public function upload( return $this->render('admin/dossier/document-status.html.twig', [ 'dossier' => $dossier, + 'uploadStatus' => $dossier->getUploadStatus(), ]); } @@ -106,7 +100,7 @@ public function details( if ($removeForm->isSubmitted() && $removeForm->isValid()) { $this->documentService->removeDocumentFromDossier($dossier, $document); - $this->addFlash('success', $this->translator->trans('Document has been removed')); + $this->addFlash('backend', ['success' => $this->translator->trans('Document has been removed')]); return $this->redirectToRoute('app_admin_dossier_edit', ['dossierId' => $dossier->getDossierNr()]); } @@ -115,7 +109,7 @@ public function details( if ($ingestForm->isSubmitted() && $ingestForm->isValid()) { $this->ingester->ingest($document, new Options()); - $this->addFlash('success', $this->translator->trans('Document is scheduled for ingestion')); + $this->addFlash('backend', ['success' => $this->translator->trans('Document is scheduled for ingestion')]); return $this->redirectToRoute('app_admin_dossier_edit', ['dossierId' => $dossier->getDossierNr()]); } @@ -127,4 +121,59 @@ public function details( 'ingestForm' => $ingestForm->createView(), ]); } + + #[Route('/balie/dossier/{dossierId}/document/{documentId}/withdraw', name: 'app_admin_dossier_document_withdraw', methods: ['GET', 'POST'])] + public function docWithdraw( + Breadcrumbs $breadcrumbs, + #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier, + #[MapEntity(mapping: ['documentId' => 'documentNr'])] Document $document, + Request $request, + ): Response { + if (! $dossier->getDocuments()->contains($document)) { + throw new NotFoundHttpException('Document not found'); + } + + if ($document->isWithdrawn()) { + $this->addFlash('backend', ['error' => $this->translator->trans('Document is already withdrawn')]); + + return $this->redirectToRoute( + 'app_admin_dossier_document_details', + ['dossierId' => $dossier->getDossierNr(), 'documentId' => $document->getDocumentNr()] + ); + } + + $form = $this->createForm(WithdrawFormType::class); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var WithdrawReason $reason */ + $reason = $form->get('reason')->getData(); + + /** @var string $explanation */ + $explanation = $form->get('explanation')->getData(); + + $this->documentService->withdraw($document, $reason, $explanation); + $this->addFlash('backend', ['success' => $this->translator->trans('Document has been withdrawn')]); + + return $this->redirectToRoute( + 'app_admin_dossier_document_details', + ['dossierId' => $dossier->getDossierNr(), 'documentId' => $document->getDocumentNr()] + ); + } + + $breadcrumbs->addRouteItem('Home', 'app_home'); + $breadcrumbs->addRouteItem('Admin', 'app_admin'); + $breadcrumbs->addRouteItem('Dossier management', 'app_admin_dossiers'); + $breadcrumbs->addRouteItem( + 'Document', + 'app_admin_dossier_document_details', + ['dossierId' => $dossier->getDossierNr(), 'documentId' => $document->getDocumentNr()] + ); + $breadcrumbs->addItem('Intrekken'); + + return $this->render('admin/dossier/document-withdraw.html.twig', [ + 'dossier' => $dossier, + 'document' => $document, + 'form' => $form->createView(), + ]); + } } diff --git a/src/Controller/Admin/Dossier/DossierController.php b/src/Controller/Admin/Dossier/DossierController.php index bb30bcf5..7b1c3fa2 100644 --- a/src/Controller/Admin/Dossier/DossierController.php +++ b/src/Controller/Admin/Dossier/DossierController.php @@ -4,6 +4,7 @@ namespace App\Controller\Admin\Dossier; +use App\Entity\Document; use App\Entity\Dossier; use App\Form\Document\IngestFormType; use App\Form\Document\RemoveFormType; @@ -11,18 +12,18 @@ use App\Form\Dossier\SearchFormType; use App\Form\Dossier\StateChangeFormType; use App\Service\DossierService; -use App\Service\Elastic\ElasticService; -use App\Service\Ingest\IngestService; -use App\Service\Ingest\Options; +use App\Service\Inventory\ProcessInventoryResult; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Knp\Component\Pager\PaginatorInterface; +use Psr\Log\LoggerInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -33,24 +34,14 @@ */ class DossierController extends AbstractController { - protected EntityManagerInterface $doctrine; - protected PaginatorInterface $paginator; - protected DossierService $dossierService; - protected IngestService $ingester; - protected ElasticService $elasticService; + protected const MAX_ITEMS_PER_PAGE = 100; public function __construct( - EntityManagerInterface $doctrine, - PaginatorInterface $paginator, - DossierService $dossierService, - IngestService $ingester, - ElasticService $elasticService, + private readonly EntityManagerInterface $doctrine, + private readonly PaginatorInterface $paginator, + private readonly DossierService $dossierService, + private readonly LoggerInterface $logger, ) { - $this->doctrine = $doctrine; - $this->paginator = $paginator; - $this->dossierService = $dossierService; - $this->ingester = $ingester; - $this->elasticService = $elasticService; } #[Route('/balie/dossiers', name: 'app_admin_dossiers', methods: ['GET'])] @@ -72,7 +63,7 @@ public function index(Breadcrumbs $breadcrumbs, Request $request): Response $pagination = $this->paginator->paginate( $query, $request->query->getInt('page', 1), - 10 + self::MAX_ITEMS_PER_PAGE, ); return $this->render('admin/dossier/index.html.twig', [ @@ -81,6 +72,30 @@ public function index(Breadcrumbs $breadcrumbs, Request $request): Response ]); } + #[Route('/balie/dossiers/search', name: 'app_admin_dossiers_search', methods: ['POST'])] + public function search(Request $request): Response + { + $searchTerm = urldecode(strval($request->query->get('q', ''))); + + $dossiers = $this->doctrine->getRepository(Dossier::class)->findBySearchTerm($searchTerm, 4); + $documents = $this->doctrine->getRepository(Document::class)->findBySearchTerm($searchTerm, 4); + + $ret = [ + 'results' => json_encode( + $this->renderView( + 'admin/dossier/search.html.twig', + [ + 'dossiers' => $dossiers, + 'documents' => $documents, + ], + ), + JSON_THROW_ON_ERROR, + ), + ]; + + return new JsonResponse($ret); + } + #[Route('/balie/dossier/new', name: 'app_admin_dossier_new', methods: ['GET', 'POST'])] public function new(Breadcrumbs $breadcrumbs, Request $request): Response { @@ -94,18 +109,38 @@ public function new(Breadcrumbs $breadcrumbs, Request $request): Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - /** @var UploadedFile $file */ - $file = $form->get('inventory')->getData(); - $errors = $this->dossierService->create($dossier, $file); + /** @var UploadedFile $inventoryUpload */ + $inventoryUpload = $form->get('inventory')->getData(); + /** @var UploadedFile $decisionUpload */ + $decisionUpload = $form->get('decision_document')->getData(); + + if ($inventoryUpload) { + $this->logger->info('uploaded inventory file', [ + 'path' => $inventoryUpload->getRealPath(), + 'original_file' => $inventoryUpload->getClientOriginalName(), + 'size' => $inventoryUpload->getSize(), + 'file_hash' => hash_file('sha256', $inventoryUpload->getRealPath()), + ]); + } - if (! count($errors)) { + if ($decisionUpload) { + $this->logger->info('uploaded decision file', [ + 'path' => $decisionUpload->getRealPath(), + 'original_file' => $decisionUpload->getClientOriginalName(), + 'size' => $decisionUpload->getSize(), + 'file_hash' => hash_file('sha256', $decisionUpload->getRealPath()), + ]); + } + + $result = $this->dossierService->create($dossier, $inventoryUpload, $decisionUpload); + if ($result->isSuccessful()) { // All is good, we can safely return to dossier list - $this->addFlash('success', 'Dossier has been created successfully'); + $this->addFlash('backend', ['success' => 'Dossier has been created successfully']); return $this->redirectToRoute('app_admin_dossiers'); } - $this->addFormErrors($form, $errors); + $this->addFormErrors($form, $result); } return $this->render('admin/dossier/new.html.twig', [ @@ -113,7 +148,22 @@ public function new(Breadcrumbs $breadcrumbs, Request $request): Response ]); } - #[Route('/balie/dossier/{dossierId}', name: 'app_admin_dossier_edit', methods: ['GET', 'POST'])] + #[Route('/balie/dossier/{dossierId}', name: 'app_admin_dossier', methods: ['GET'])] + public function dossier( + Breadcrumbs $breadcrumbs, + #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier + ): Response { + $breadcrumbs->addRouteItem('Home', 'app_home'); + $breadcrumbs->addRouteItem('Balie', 'app_admin'); + $breadcrumbs->addRouteItem('Dossier management', 'app_admin_dossiers'); + $breadcrumbs->addItem('View dossier'); + + return $this->render('admin/dossier/view.html.twig', [ + 'dossier' => $dossier, + ]); + } + + #[Route('/balie/dossier/{dossierId}/edit', name: 'app_admin_dossier_edit', methods: ['GET', 'POST'])] public function edit( Breadcrumbs $breadcrumbs, Request $request, @@ -148,15 +198,6 @@ public function edit( protected function applyFilter(FormInterface $form, QueryBuilder $queryBuilder): void { - $searchTerm = strval($form->get('searchterm')->getData()); - if (! empty($searchTerm)) { - $queryBuilder->andWhere('LOWER(dos.title) LIKE :filter - OR LOWER(dos.status) LIKE :filter - OR LOWER(dos.dossierNr) LIKE :filter - OR inq.casenr LIKE :filter') - ->setParameter('filter', '%' . strtolower($searchTerm) . '%'); - } - /** @var string[] $statusFilters */ $statusFilters = $form->get('status')->getData(); if (is_array($statusFilters) && count($statusFilters) > 0) { @@ -187,12 +228,12 @@ protected function handleStateForm(Request $request, Dossier $dossier): ?Respons try { $this->dossierService->changeState($dossier, strval($stateForm->get('state')->getData())); } catch (\Exception $e) { - $this->addFlash('danger', 'Dossier status could not be changed due to incorrect state: ' . $e->getMessage()); + $this->addFlash('backend', ['success' => 'Dossier status could not be changed due to incorrect state: ' . $e->getMessage()]); return $this->redirectToRoute('app_admin_dossiers'); } - $this->addFlash('success', 'Dossier status has been changed'); + $this->addFlash('backend', ['success' => 'Dossier status has been changed']); return $this->redirectToRoute('app_admin_dossiers'); } @@ -207,7 +248,7 @@ protected function handleRemoveForm(Request $request, Dossier $dossier): ?Respon } $this->dossierService->remove($dossier); - $this->addFlash('success', 'Dossier has been removed'); + $this->addFlash('backend', ['success' => 'Dossier has been removed']); return $this->redirectToRoute('app_admin_dossiers'); } @@ -221,12 +262,9 @@ protected function handleIngestForm(Request $request, Dossier $dossier): ?Respon return null; } - $this->elasticService->updateDossier($dossier, false); - foreach ($dossier->getDocuments() as $document) { - $this->ingester->ingest($document, new Options()); - } + $this->dossierService->dispatchIngest($dossier); - $this->addFlash('success', 'Dossier is scheduled for ingestion'); + $this->addFlash('backend', ['success' => 'Dossier is scheduled for ingestion']); return $this->redirectToRoute('app_admin_dossiers'); } @@ -240,36 +278,53 @@ protected function handleUpdateForm(Request $request, Dossier $dossier): ?Respon return null; } - /** @var UploadedFile $file */ - $file = $form->get('inventory')->getData(); - $errors = $this->dossierService->update($dossier, $file); + /** @var UploadedFile $inventoryUpload */ + $inventoryUpload = $form->get('inventory')->getData(); + /** @var UploadedFile $decisionUpload */ + $decisionUpload = $form->get('decision_document')->getData(); + + if ($decisionUpload) { + $this->logger->info('uploaded decision file', [ + 'path' => $decisionUpload->getRealPath(), + 'original_file' => $decisionUpload->getClientOriginalName(), + 'size' => $decisionUpload->getSize(), + 'file_hash' => hash_file('sha256', $decisionUpload->getRealPath()), + ]); + } + + if ($inventoryUpload) { + $this->logger->info('uploaded inventory file', [ + 'path' => $inventoryUpload->getRealPath(), + 'original_file' => $inventoryUpload->getClientOriginalName(), + 'size' => $inventoryUpload->getSize(), + 'file_hash' => hash_file('sha256', $inventoryUpload->getRealPath()), + ]); + } - if (! count($errors)) { + $result = $this->dossierService->update($dossier, $inventoryUpload, $decisionUpload); + + if ($result->isSuccessful()) { // All is good, we can safely return to dossier list - $this->addFlash('success', 'Dossier has been updated successfully'); + $this->addFlash('backend', ['success' => 'Dossier has been updated successfully']); return $this->redirectToRoute('app_admin_dossiers'); } // Add errors to form - $this->addFormErrors($form->get('inventory'), $errors); + $this->addFormErrors($form->get('inventory'), $result); return null; } - /** - * @param array $errors - */ - protected function addFormErrors(FormInterface $form, array $errors): void + protected function addFormErrors(FormInterface $form, ProcessInventoryResult $result): void { - // Add all errors to the form - foreach ($errors as $lineNum => $lineErrors) { + foreach ($result->getGenericErrors() as $errorMessage) { + $form->addError(new FormError($errorMessage)); + } + + foreach ($result->getRowErrors() as $lineNum => $lineErrors) { foreach ($lineErrors as $error) { - if ($lineNum == 0) { - $form->addError(new FormError($error)); - } else { - $form->addError(new FormError(sprintf('Line %d: %s', $lineNum, $error))); - } + $form->addError(new FormError(sprintf('Line %d: %s', $lineNum, $error))); } } } diff --git a/src/Controller/Admin/ElasticController.php b/src/Controller/Admin/ElasticController.php index 05fd5b98..b1a6d231 100644 --- a/src/Controller/Admin/ElasticController.php +++ b/src/Controller/Admin/ElasticController.php @@ -77,7 +77,7 @@ public function create(Breadcrumbs $breadcrumbs, Request $request): Response ); $this->bus->dispatch($message); - $this->addFlash('success', $this->translator->trans('Elasticsearch rollover initiated')); + $this->addFlash('backend', ['success' => $this->translator->trans('Elasticsearch rollover initiated')]); return $this->redirectToRoute('app_admin_elastic'); } @@ -97,7 +97,7 @@ public function details(Breadcrumbs $breadcrumbs, string $indexName): Response $indices = $this->indexService->find($indexName); if (empty($indices)) { - $this->addFlash('error', 'Invalid elasticsearch index'); + $this->addFlash('backend', ['error' => 'Invalid elasticsearch index']); return $this->redirectToRoute('app_admin_elastic'); } @@ -121,7 +121,7 @@ public function makeLive(Breadcrumbs $breadcrumbs, Request $request, string $ind $indices = $this->indexService->find($indexName); if (empty($indices)) { - $this->addFlash('error', 'Invalid elasticsearch index'); + $this->addFlash('backend', ['error' => 'Invalid elasticsearch index']); return $this->redirectToRoute('app_admin_elastic'); } @@ -144,7 +144,7 @@ public function makeLive(Breadcrumbs $breadcrumbs, Request $request, string $ind ); $this->bus->dispatch($message); - $this->addFlash('success', $this->translator->trans('Elasticsearch index switch initiated')); + $this->addFlash('backend', ['success' => $this->translator->trans('Elasticsearch index switch initiated')]); return $this->redirectToRoute('app_admin_elastic'); } diff --git a/src/Controller/Admin/PrefixController.php b/src/Controller/Admin/PrefixController.php index 3b13fcff..9f493243 100644 --- a/src/Controller/Admin/PrefixController.php +++ b/src/Controller/Admin/PrefixController.php @@ -55,7 +55,7 @@ public function create(Breadcrumbs $breadcrumbs, Request $request): Response $this->doctrine->persist($prefix); $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Prefix created')); + $this->addFlash('backend', ['success' => $this->translator->trans('Prefix created')]); return $this->redirectToRoute('app_admin_prefixes'); } @@ -78,7 +78,7 @@ public function editPrefix(Breadcrumbs $breadcrumbs, Request $request, DocumentP $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Prefix modified')); + $this->addFlash('backend', ['success' => $this->translator->trans('Prefix modified')]); return $this->redirectToRoute('app_admin_prefixes'); } diff --git a/src/Controller/Admin/RequestController.php b/src/Controller/Admin/RequestController.php index 214b856a..8e1743eb 100644 --- a/src/Controller/Admin/RequestController.php +++ b/src/Controller/Admin/RequestController.php @@ -18,6 +18,8 @@ class RequestController extends AbstractController { + protected const MAX_ITEMS_PER_PAGE = 100; + protected EntityManagerInterface $doctrine; protected UserService $userService; protected PaginatorInterface $paginator; @@ -41,7 +43,7 @@ public function index(Breadcrumbs $breadcrumbs, Request $request): Response $pagination = $this->paginator->paginate( $query, $request->query->getInt('page', 1), - 10 + self::MAX_ITEMS_PER_PAGE ); return $this->render('admin/request/index.html.twig', [ diff --git a/src/Controller/Admin/TokenController.php b/src/Controller/Admin/TokenController.php index cfa35157..a67a542d 100644 --- a/src/Controller/Admin/TokenController.php +++ b/src/Controller/Admin/TokenController.php @@ -56,7 +56,7 @@ public function create(Breadcrumbs $breadcrumbs, Request $request): Response $this->doctrine->persist($token); $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Token created')); + $this->addFlash('backend', ['success' => $this->translator->trans('Token created')]); return $this->redirectToRoute('app_admin_tokens'); } @@ -79,7 +79,7 @@ public function modify(Breadcrumbs $breadcrumbs, Request $request, Token $token) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('Token modified')); + $this->addFlash('backend', ['success' => $this->translator->trans('Token modified')]); return $this->redirectToRoute('app_admin_tokens'); } diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index d74eacf4..5e152017 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -64,7 +64,7 @@ public function create(Breadcrumbs $breadcrumbs, Request $request): Response if ($userForm->isSubmitted() && $userForm->isValid()) { /** @var array{name: string, email: string, roles: string[]} $data */ $data = $userForm->getData(); - ['plainPassword' => $plainPassword, 'user' => $user ] = $this->userService->createUser( + ['plainPassword' => $plainPassword, 'user' => $user] = $this->userService->createUser( name: $data['name'], email: $data['email'], roles: $data['roles'] @@ -91,7 +91,7 @@ public function modify(Breadcrumbs $breadcrumbs, Request $request, User $user): $breadcrumbs->addItem('Edit user'); if ($user === $this->getUser()) { - $this->addFlash('danger', $this->translator->trans('Modifying your own account is not allowed')); + $this->addFlash('backend', ['warning' => $this->translator->trans('Modifying your own account is not allowed')]); return $this->redirectToRoute('app_admin_users'); } @@ -133,7 +133,7 @@ protected function handleInfoForm(Request $request, User $user): ?Response } $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('The user has been modified')); + $this->addFlash('backend', ['success' => $this->translator->trans('The user has been modified')]); return $this->redirectToRoute('app_admin'); } @@ -148,7 +148,7 @@ protected function handleRoleForm(Request $request, User $user): ?Response } $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('User roles have been modified')); + $this->addFlash('backend', ['success' => $this->translator->trans('User roles have been modified')]); return $this->redirectToRoute('app_admin'); } @@ -164,7 +164,7 @@ protected function handleDisableForm(Request $request, User $user): ?Response $user->setEnabled(false); $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('User has been disabled')); + $this->addFlash('backend', ['success' => $this->translator->trans('User has been disabled')]); return $this->redirectToRoute('app_admin'); } @@ -180,7 +180,7 @@ protected function handleEnableForm(Request $request, User $user): ?Response $user->setEnabled(true); $this->doctrine->flush(); - $this->addFlash('success', $this->translator->trans('User has been enabled')); + $this->addFlash('backend', ['success' => $this->translator->trans('User has been enabled')]); return $this->redirectToRoute('app_admin'); } diff --git a/src/Controller/DocumentController.php b/src/Controller/DocumentController.php index 848788a7..062783fb 100644 --- a/src/Controller/DocumentController.php +++ b/src/Controller/DocumentController.php @@ -6,10 +6,11 @@ use App\Entity\Document; use App\Entity\Dossier; +use App\Repository\DocumentRepository; +use App\Service\DossierService; use App\Service\Search\SearchService; use App\Service\Storage\DocumentStorageService; use App\Service\Storage\ThumbnailStorageService; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -21,24 +22,14 @@ class DocumentController extends AbstractController { - protected EntityManagerInterface $doctrine; - protected DocumentStorageService $documentStorage; - protected ThumbnailStorageService $thumbnailStorage; - protected SearchService $searchService; - protected TranslatorInterface $translator; - public function __construct( - EntityManagerInterface $doctrine, - DocumentStorageService $documentStorage, - ThumbnailStorageService $thumbnailStorage, - SearchService $searchService, - TranslatorInterface $translator + private readonly DocumentStorageService $documentStorage, + private readonly ThumbnailStorageService $thumbnailStorage, + private readonly SearchService $searchService, + private readonly TranslatorInterface $translator, + private readonly DocumentRepository $documentRepository, + private readonly DossierService $dossierService, ) { - $this->doctrine = $doctrine; - $this->documentStorage = $documentStorage; - $this->thumbnailStorage = $thumbnailStorage; - $this->searchService = $searchService; - $this->translator = $translator; } #[Route('/dossier/{dossierId}/document/{documentId}', name: 'app_document_detail', methods: ['GET'])] @@ -51,24 +42,20 @@ public function detail( $breadcrumbs->addRouteItem('Dossier', 'app_dossier_detail', ['dossierId' => $dossier->getDossierNr()]); $breadcrumbs->addItem('Document'); - if (! $dossier->getDocuments()->contains($document)) { - throw new NotFoundHttpException('Document not found'); + if (! $this->dossierService->isViewingAllowed($dossier, $document)) { + throw $this->createNotFoundException('Document or dossier not found'); } - $thread = $this->doctrine->getRepository(Document::class)->findBy(['threadId' => $document->getThreadId()], ['documentDate' => 'ASC']); - $family = $this->doctrine->getRepository(Document::class)->findBy(['familyId' => $document->getFamilyId()], ['documentDate' => 'ASC']); - - // This could be easier with a criteria - $family = array_filter($family, function (Document $doc) use ($document) { - return $doc->getDocumentNr() !== $document->getDocumentNr(); - }); + if (! $dossier->getDocuments()->contains($document)) { + throw new NotFoundHttpException('Document not found in dossier'); + } return $this->render('document/details.html.twig', [ 'ingested' => $this->searchService->isIngested($document), 'dossier' => $dossier, 'document' => $document, - 'thread' => $thread, - 'family' => $family, + 'thread' => $this->documentRepository->getRelatedDocumentsByThread($document), + 'family' => $this->documentRepository->getRelatedDocumentsByFamily($document), ]); } @@ -77,8 +64,12 @@ public function download( #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier, #[MapEntity(mapping: ['documentId' => 'documentNr'])] Document $document ): StreamedResponse { + if (! $this->dossierService->isViewingAllowed($dossier, $document)) { + throw $this->createNotFoundException('Document or dossier not found'); + } + if (! $dossier->getDocuments()->contains($document)) { - throw new NotFoundHttpException('Document not found'); + throw new NotFoundHttpException('Document not found in dossier'); } $stream = $this->documentStorage->retrieveResourceDocument($document); @@ -86,10 +77,10 @@ public function download( throw new NotFoundHttpException(); } - // @todo: caching headers et al $response = new StreamedResponse(); - $response->headers->set('Content-Type', $document->getMimetype()); - + $response->headers->set('Content-Type', $document->getFileInfo()->getMimetype()); + $response->headers->set('Content-Length', (string) $document->getFileInfo()->getSize()); + $response->headers->set('Last-Modified', $document->getUpdatedAt()->format('D, d M Y H:i:s') . ' GMT'); $response->setCallback(function () use ($stream) { fpassthru($stream); }); @@ -108,8 +99,12 @@ public function debugPage( #[MapEntity(mapping: ['documentId' => 'documentNr'])] Document $document, string $pageNr ): Response { + if (! $this->dossierService->isViewingAllowed($dossier, $document)) { + throw $this->createNotFoundException('Document or dossier not found'); + } + if (! $dossier->getDocuments()->contains($document)) { - throw new NotFoundHttpException('Document not found'); + throw new NotFoundHttpException('Document not found in dossier'); } $content = $this->searchService->getPageContent($document, intval($pageNr)); @@ -131,12 +126,17 @@ public function downloadPage( #[MapEntity(mapping: ['documentId' => 'documentNr'])] Document $document, string $pageNr ): StreamedResponse { + if (! $this->dossierService->isViewingAllowed($dossier, $document)) { + throw $this->createNotFoundException('Document or dossier not found'); + } + if (! $dossier->getDocuments()->contains($document)) { - throw new NotFoundHttpException('Document not found'); + throw new NotFoundHttpException('Document not found in dossier'); } // No file found (yet), just the document record - if ($document->getFilepath() == null || ! $document->isUploaded()) { + $file = $document->getFileInfo(); + if ($file->getPath() === null || ! $file->isUploaded()) { throw new NotFoundHttpException(); } @@ -145,11 +145,13 @@ public function downloadPage( throw new NotFoundHttpException(); } - // @todo: caching headers et al - $response = new StreamedResponse(function () use ($stream) { + $response = new StreamedResponse(); + $response->headers->set('Content-Type', $file->getMimetype()); + $response->headers->set('Content-Length', (string) $file->getSize()); + $response->headers->set('Last-Modified', $document->getUpdatedAt()->format('D, d M Y H:i:s') . ' GMT'); + $response->setCallback(function () use ($stream) { fpassthru($stream); }); - $response->headers->set('Content-Type', $document->getMimetype()); return $response; } @@ -165,24 +167,35 @@ public function thumbnailPage( #[MapEntity(mapping: ['documentId' => 'documentNr'])] Document $document, string $pageNr ): StreamedResponse { + if (! $this->dossierService->isViewingAllowed($dossier, $document)) { + throw $this->createNotFoundException('Document or dossier not found'); + } + if (! $dossier->getDocuments()->contains($document)) { - throw new NotFoundHttpException('Document not found'); + throw new NotFoundHttpException('Document not found in dossier'); } + $fileSize = $this->thumbnailStorage->fileSize($document, intval($pageNr)); $stream = $this->thumbnailStorage->retrieveResource($document, intval($pageNr)); if (! $stream) { // Display default placeholder thumbnail if we haven't found a thumbnail for given document/pageNr $path = sprintf('%s/%s', $this->getParameter('kernel.project_dir') . '/public', 'placeholder.png'); + $fileSize = filesize($path); $stream = fopen($path, 'rb'); if (! $stream) { throw new NotFoundHttpException(); } } - // @todo: caching headers et al $response = new StreamedResponse(); $response->headers->set('Content-Type', 'image/png'); - + $response->headers->set('Content-Length', (string) $fileSize); + $response->setCache([ + 'public' => true, + 'max_age' => 3600, + 's_maxage' => 3600, + 'immutable' => true, + ]); $response->setCallback(function () use ($stream) { fpassthru($stream); }); diff --git a/src/Controller/DossierController.php b/src/Controller/DossierController.php index 3dbfd3b6..cff46433 100644 --- a/src/Controller/DossierController.php +++ b/src/Controller/DossierController.php @@ -7,28 +7,37 @@ use App\Entity\BatchDownload; use App\Entity\Dossier; use App\Message\GenerateArchiveMessage; +use App\Service\ArchiveService; +use App\Service\DossierService; use App\Service\Search\Model\Config; +use App\Service\Storage\DocumentStorageService; use Doctrine\ORM\EntityManagerInterface; +use Knp\Component\Pager\PaginatorInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Contracts\Translation\TranslatorInterface; use WhiteOctober\BreadcrumbsBundle\Model\Breadcrumbs; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class DossierController extends AbstractController { - protected EntityManagerInterface $doctrine; - protected MessageBusInterface $messageBus; - protected TranslatorInterface $translator; + protected const MAX_ITEMS_PER_PAGE = 100; - public function __construct(EntityManagerInterface $doctrine, MessageBusInterface $messageBus, TranslatorInterface $translator) - { - $this->doctrine = $doctrine; - $this->messageBus = $messageBus; - $this->translator = $translator; + public function __construct( + private readonly EntityManagerInterface $doctrine, + private readonly MessageBusInterface $messageBus, + private readonly DocumentStorageService $documentStorage, + private readonly DossierService $dossierService, + private readonly PaginatorInterface $paginator, + private readonly ArchiveService $archiveService, + ) { } #[Route('/dossiers', name: 'app_dossier_index', methods: ['GET'])] @@ -40,17 +49,44 @@ public function index(): Response #[Route('/dossier/{dossierId}', name: 'app_dossier_detail', methods: ['GET'])] public function detail( #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier, - Breadcrumbs $breadcrumbs + Breadcrumbs $breadcrumbs, + Request $request, ): Response { $breadcrumbs->addRouteItem('Home', 'app_home'); $breadcrumbs->addItem('Dossier'); - if (! $dossier->isVisible()) { + if (! $this->dossierService->isViewingAllowed($dossier)) { throw $this->createNotFoundException('Dossier not found'); } + // Split the documents by judgement for display purposes + $publicDocs = []; + $notPublicDocs = []; + foreach ($dossier->getDocuments() as $document) { + if ($document->getJudgement()?->isAtLeastPartialPublic()) { + $publicDocs[] = $document; + } else { + $notPublicDocs[] = $document; + } + } + + $publicPagination = $this->paginator->paginate( + $publicDocs, + $request->query->getInt('pu', 1), + self::MAX_ITEMS_PER_PAGE, + ['pageParameterName' => 'pu'], + ); + $notPublicPagination = $this->paginator->paginate( + $notPublicDocs, + $request->query->getInt('pn', 1), + self::MAX_ITEMS_PER_PAGE, + ['pageParameterName' => 'pn'], + ); + return $this->render('dossier/details.html.twig', [ 'dossier' => $dossier, + 'public_docs' => $publicPagination, + 'not_public_docs' => $notPublicPagination, ]); } @@ -59,7 +95,7 @@ public function createBatch( Request $request, #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier, ): Response { - if (! $dossier->isVisible()) { + if (! $this->dossierService->isViewingAllowed($dossier)) { throw $this->createNotFoundException('Dossier not found'); } @@ -78,10 +114,13 @@ public function createBatch( } } - if (count($documents) === 0) { - $this->addFlash('warning', $this->translator->trans('No documents selected')); - - return $this->redirectToRoute('app_dossier_detail', ['dossierId' => $dossier->getDossierNr()]); + // If a batch already exists with the given documents, redirect to that batch. + $batch = $this->archiveService->archiveExists($dossier, $documents); + if ($batch) { + return $this->redirectToRoute('app_dossier_batch_detail', [ + 'dossierId' => $dossier->getDossierNr(), + 'batchId' => $batch->getId(), + ]); } $batch = new BatchDownload(); @@ -113,7 +152,7 @@ public function batch( $breadcrumbs->addRouteItem('Dossier', 'app_dossier_detail', ['dossierId' => $dossier->getDossierNr()]); $breadcrumbs->addItem('Download'); - if (! $dossier->isVisible()) { + if (! $this->dossierService->isViewingAllowed($dossier)) { throw $this->createNotFoundException('Dossier not found'); } @@ -122,4 +161,99 @@ public function batch( 'batch' => $batch, ]); } + + #[Route('/dossier/{dossierId}/batch/{batchId}/download', name: 'app_dossier_batch_download', methods: ['GET'])] + public function batchDownload( + #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier, + #[MapEntity(mapping: ['batchId' => 'id'])] BatchDownload $batch, + ): Response { + if (! $this->dossierService->isViewingAllowed($dossier)) { + throw $this->createNotFoundException('Dossier not found'); + } + + if ($batch->getStatus() !== BatchDownload::STATUS_COMPLETED) { + return $this->redirectToRoute('app_dossier_batch_detail', [ + 'dossierId' => $dossier->getDossierNr(), + 'batchId' => $batch->getId(), + ]); + } + + $stream = $this->archiveService->getZipStream($batch); + if (! $stream) { + throw new NotFoundHttpException(); + } + + $response = new StreamedResponse(); + $response->headers->set('Content-Type', 'application/zip'); + $response->headers->set('Content-Length', $batch->getSize()); + $response->headers->set('Content-Disposition', 'attachment; filename="' . $batch->getFilename() . '"'); + // Since the batch is immutable, we can cache it for a while + $response->setCache([ + 'public' => true, + 'max_age' => 48 * 3600, + 's_maxage' => 48 * 3600, + 'immutable' => true, + ]); + $response->setCallback(function () use ($stream) { + fpassthru($stream); + }); + + return $response; + } + + #[Route('/dossier/{dossierId}/inventory/download', name: 'app_dossier_inventory_download', methods: ['GET'])] + public function downloadInventory( + #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier + ): StreamedResponse { + if (! $this->dossierService->isViewingAllowed($dossier)) { + throw $this->createNotFoundException('Dossier not found'); + } + + $inventory = $dossier->getInventory(); + if (! $inventory) { + throw $this->createNotFoundException('Dossier inventory not found'); + } + + $stream = $this->documentStorage->retrieveResourceDocument($inventory); + if (! $stream) { + throw new NotFoundHttpException(); + } + + $response = new StreamedResponse(); + $response->headers->set('Content-Type', $inventory->getFileInfo()->getMimetype()); + $response->headers->set('Content-Length', (string) $inventory->getFileInfo()->getSize()); + $response->headers->set('Content-Disposition', 'attachment; filename="' . $inventory->getFileInfo()->getName() . '"'); + $response->headers->set('Last-Modified', $inventory->getUpdatedAt()->format('D, d M Y H:i:s') . ' GMT'); + $response->setCallback(function () use ($stream) { + fpassthru($stream); + }); + + return $response; + } + + #[Route('/dossier/{dossierId}/decision/download', name: 'app_dossier_decision_download', methods: ['GET'])] + public function downloadDecision( + #[MapEntity(mapping: ['dossierId' => 'dossierNr'])] Dossier $dossier + ): StreamedResponse { + $decisionDocument = $dossier->getDecisionDocument(); + if (! $decisionDocument) { + throw $this->createNotFoundException('Dossier decision document not found'); + } + + $stream = $this->documentStorage->retrieveResourceDocument($decisionDocument); + if (! $stream) { + throw new NotFoundHttpException(); + } + + $response = new StreamedResponse(); + $response->headers->set('Content-Type', $decisionDocument->getFileInfo()->getMimetype()); + $response->headers->set('Content-Length', (string) $decisionDocument->getFileInfo()->getSize()); + $response->headers->set('Content-Disposition', 'attachment; filename="' . $decisionDocument->getFileInfo()->getName() . '"'); + $response->headers->set('Last-Modified', $decisionDocument->getUpdatedAt()->format('D, d M Y H:i:s') . ' GMT'); + $response->setCallback(function () use ($stream) { + fpassthru($stream); + }); + + return $response; + } } diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index 7d59d027..502f437f 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -47,8 +47,9 @@ public function index(Request $request, Breadcrumbs $breadcrumbs): Response } // From here we always have a 'q' from the query string - $q = strval($request->query->get('q')); - if (! empty($q)) { + if ($request->query->has('q')) { + $q = strval($request->query->get('q')); + return new RedirectResponse($this->generateUrl('app_search', ['q' => $q])); } diff --git a/src/Controller/InquiryController.php b/src/Controller/InquiryController.php index 609c76b0..0bf7e165 100644 --- a/src/Controller/InquiryController.php +++ b/src/Controller/InquiryController.php @@ -7,27 +7,58 @@ use App\Entity\Inquiry; use App\Service\InquiryService; use Doctrine\ORM\EntityManagerInterface; +use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class InquiryController extends AbstractController { + protected const MAX_ITEMS_PER_PAGE = 100; + public function __construct( protected EntityManagerInterface $doctrine, protected Security $security, - protected InquiryService $inquiryService + protected InquiryService $inquiryService, + private readonly PaginatorInterface $paginator, ) { } #[Route('/inquiry/{token}', name: 'app_inquiry_detail', methods: ['GET'])] - public function detail(Inquiry $inquiry): Response + public function detail(Inquiry $inquiry, Request $request): Response { $this->inquiryService->saveInquiry($inquiry); + // Split the documents by judgement for display purposes + $publicDocs = []; + $notPublicDocs = []; + foreach ($inquiry->getDocuments() as $document) { + if ($document->getJudgement()?->isAtLeastPartialPublic()) { + $publicDocs[] = $document; + } else { + $notPublicDocs[] = $document; + } + } + + $publicPagination = $this->paginator->paginate( + $publicDocs, + $request->query->getInt('pu', 1), + self::MAX_ITEMS_PER_PAGE, + ['pageParameterName' => 'pu'], + ); + $notPublicPagination = $this->paginator->paginate( + $notPublicDocs, + $request->query->getInt('pn', 1), + self::MAX_ITEMS_PER_PAGE, + ['pageParameterName' => 'pn'], + ); + return $this->render('inquiry/index.html.twig', [ 'inquiry' => $inquiry, + 'public_docs' => $publicPagination, + 'not_public_docs' => $notPublicPagination, ]); } } diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index 0132d169..9379bc9d 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -5,9 +5,11 @@ namespace App\Controller; use App\Service\Search\ConfigFactory; +use App\Service\Search\Model\Config; use App\Service\Search\SearchService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -84,9 +86,6 @@ public function ajaxResultMinimalistic(Request $request): Response #[Route('/search', name: 'app_search')] public function search(Request $request, Breadcrumbs $breadcrumbs): Response { - $breadcrumbs->addRouteItem('Home', 'app_home'); - $breadcrumbs->addItem('Search results'); - // If we have a POST request, we have a search query in the body. Redirect to GET request // so we have the q in the query string. if ($request->isMethod('POST')) { @@ -99,10 +98,15 @@ public function search(Request $request, Breadcrumbs $breadcrumbs): Response )); } - // From here we always have a 'q' from the query string - $q = strval($request->query->get('q')); - $config = $this->configFactory->createFromRequest($request); + + $breadcrumbs->addRouteItem('Home', 'app_home'); + if ($config->searchType === Config::TYPE_DOSSIER) { + $breadcrumbs->addItem('All published dossiers'); + } else { + $breadcrumbs->addItem('Search'); + } + $result = $this->searchService->search($config); if ($result->hasFailed()) { return $this->render('search/result-failure.html.twig', [ @@ -116,8 +120,24 @@ public function search(Request $request, Breadcrumbs $breadcrumbs): Response } #[Route('/browse', name: 'app_browse')] - public function browse(Breadcrumbs $breadcrumbs): Response + public function browse(Request $request, Breadcrumbs $breadcrumbs): Response { + // If we have a POST request, we have a search query in the body. Redirect to GET request + // so we have the q in the query string. + if ($request->isMethod('POST')) { + $q = strval($request->request->get('q')); + + // Redirect to GET request, so we have the q in the query string. + return $this->redirect($this->generateUrl('app_browse', ['q' => $q])); + } + + // From here we always have a 'q' from the query string + if ($request->query->has('q')) { + $q = strval($request->query->get('q')); + + return new RedirectResponse($this->generateUrl('app_search', ['q' => $q])); + } + $breadcrumbs->addRouteItem('Home', 'app_home'); $breadcrumbs->addItem('Browse'); diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 5cac923a..abb32fe8 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -25,7 +25,7 @@ public function __construct(EntityManagerInterface $doctrine, UserPasswordHasher $this->passwordEncoder = $passwordEncoder; } - #[Route(path: '/login', name: 'app_login')] + #[Route(path: '/balie/login', name: 'app_login')] public function login(AuthenticationUtils $authenticationUtils): Response { // get the login error if there is one @@ -36,13 +36,13 @@ public function login(AuthenticationUtils $authenticationUtils): Response return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); } - #[Route(path: '/logout', name: 'app_logout')] + #[Route(path: '/balie/logout', name: 'app_logout')] public function logout(): void { throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); } - #[Route(path: '/change-password', name: 'app_change_password')] + #[Route(path: '/balie/change-password', name: 'app_change_password')] public function changePassword(Request $request): Response { $form = $this->createForm(ChangePasswordType::class); diff --git a/src/Controller/StatsController.php b/src/Controller/StatsController.php index 5bac1750..38f645a5 100644 --- a/src/Controller/StatsController.php +++ b/src/Controller/StatsController.php @@ -7,18 +7,23 @@ use App\Entity\Document; use App\Entity\Dossier; use App\Entity\WorkerStats; +use App\Service\Search\Model\Config; +use App\Service\Search\SearchService; use Doctrine\ORM\EntityManagerInterface; +use Predis\Client; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class StatsController extends AbstractController { - protected EntityManagerInterface $doctrine; - - public function __construct(EntityManagerInterface $doctrine) - { - $this->doctrine = $doctrine; + public function __construct( + private readonly EntityManagerInterface $doctrine, + private readonly Client $redis, + private readonly SearchService $searchService, + private readonly string $rabbitMqStatUrl + ) { } #[Route('/prometheus', name: 'app_prometheus', methods: ['GET'])] @@ -47,4 +52,98 @@ public function prometheus(): Response return $response; } + + #[Route('/health', name: 'app_health', methods: ['GET'])] + public function health(): JsonResponse + { + $services = [ + 'postgres' => $this->isPostgresAlive(), + 'redis' => $this->isRedisAlive(), + 'elastic' => $this->isElasticAlive(), + 'rabbitmq' => $this->isRabbitMqAlive(), + ]; + + $statusCode = Response::HTTP_OK; + foreach ($services as $status) { + if ($status === false) { + $statusCode = Response::HTTP_SERVICE_UNAVAILABLE; + } + } + + $healthy = $services['postgres'] && $services['redis'] && $services['elastic'] && $services['rabbitmq']; + $response = new JsonResponse([ + 'healthy' => $healthy, + 'externals' => [ + 'postgres' => $services['postgres'], + 'redis' => $services['redis'], + 'elastic' => $services['elastic'], + 'rabbitmq' => $services['rabbitmq'], + ], + ], $statusCode); + + return $response->setPrivate(); + } + + protected function isRedisAlive(): bool + { + try { + $this->redis->connect(); + $result = $this->redis->isConnected(); + if ($result !== true) { + return false; + } + + $result = $this->redis->ping('ping'); + + return $result === 'ping'; + } catch (\Throwable) { + // ignore + } + + return false; + } + + protected function isPostgresAlive(): bool + { + try { + $result = $this->doctrine->getConnection()->fetchOne('SELECT 1'); + + return $result === 1; + } catch (\Throwable) { + // ignore + } + + return false; + } + + protected function isElasticAlive(): bool + { + try { + $result = $this->searchService->search(new Config()); + + return $result->hasFailed() === false; + } catch (\Throwable) { + // ignore + } + + return false; + } + + protected function isRabbitMqAlive(): bool + { + try { + $client = new \GuzzleHttp\Client([ + 'base_uri' => $this->rabbitMqStatUrl, + 'timeout' => 2.0, + 'connect_timeout' => 2.0, + ]); + $response = $client->get('/api/overview'); + + return $response->getStatusCode() === Response::HTTP_OK; + } catch (\Exception) { + // ignore + } + + return false; + } } diff --git a/src/DataCollector/ElasticCollector.php b/src/DataCollector/ElasticCollector.php index 5721bf08..16d3ef77 100644 --- a/src/DataCollector/ElasticCollector.php +++ b/src/DataCollector/ElasticCollector.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +/** + * A data collector for elasticsearch calls so we can display them in the debug profiler toolbar. + */ class ElasticCollector extends AbstractDataCollector { protected bool $enabled = true; diff --git a/src/DataFixtures/DepartmentFixtures.php b/src/DataFixtures/DepartmentFixtures.php index 3fdb0688..ddb80926 100644 --- a/src/DataFixtures/DepartmentFixtures.php +++ b/src/DataFixtures/DepartmentFixtures.php @@ -9,28 +9,32 @@ use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; +/** + * This is a set of fixtures for the Department and officials entities. It is not meant to be used in production. + */ class DepartmentFixtures extends Fixture { public function load(ObjectManager $manager): void { $departments = [ - 'Ministerie van Algemene Zaken', - 'Ministerie van Binnenlandse Zaken en Koninkrijksrelaties', - 'Ministerie van Buitenlandse Zaken', - 'Ministerie van Defensie', - 'Ministerie van Economische Zaken en Klimaat', - 'Ministerie van Financiën', - 'Ministerie van Infrastructuur en Waterstaat', - 'Ministerie van Justitie en Veiligheid', - 'Ministerie van Landbouw, Natuur en Voedselkwaliteit', - 'Ministerie van Onderwijs, Cultuur en Wetenschap', - 'Ministerie van Sociale Zaken en Werkgelegenheid', - 'Ministerie van Volksgezondheid, Welzijn en Sport', + 'Ministerie van Algemene Zaken' => 'AZ', + 'Ministerie van Binnenlandse Zaken en Koninkrijksrelaties' => 'BZK', + 'Ministerie van Buitenlandse Zaken' => 'BZ', + 'Ministerie van Defensie' => 'Def', + 'Ministerie van Economische Zaken en Klimaat' => 'EZK', + 'Ministerie van Financiën' => 'Fin', + 'Ministerie van Infrastructuur en Waterstaat' => 'I&W', + 'Ministerie van Justitie en Veiligheid' => 'J&V', + 'Ministerie van Landbouw, Natuur en Voedselkwaliteit' => 'LNV', + 'Ministerie van Onderwijs, Cultuur en Wetenschap' => 'OCW', + 'Ministerie van Sociale Zaken en Werkgelegenheid' => 'SZW', + 'Ministerie van Volksgezondheid, Welzijn en Sport' => 'VWS', ]; - foreach ($departments as $department) { + foreach ($departments as $department => $short) { $entity = new Department(); $entity->setName($department); + $entity->setShortTag($short); $manager->persist($entity); } diff --git a/src/Entity/BatchDownload.php b/src/Entity/BatchDownload.php index bf00ba10..e36a7417 100644 --- a/src/Entity/BatchDownload.php +++ b/src/Entity/BatchDownload.php @@ -127,4 +127,9 @@ public function setSize(string $size): static return $this; } + + public function getFilename(): string + { + return sprintf('dossier-%s-%s.zip', $this->getDossier()->getDossierNr(), $this->getId()->toBase58()); + } } diff --git a/src/Entity/Department.php b/src/Entity/Department.php index 413ac723..a9e94a35 100644 --- a/src/Entity/Department.php +++ b/src/Entity/Department.php @@ -21,6 +21,9 @@ class Department #[ORM\Column(length: 255)] private string $name; + #[ORM\Column(length: 20, nullable: true)] + private ?string $shortTag = null; + public function getId(): Uuid { return $this->id; @@ -37,4 +40,21 @@ public function setName(string $name): self return $this; } + + public function getShortTag(): ?string + { + return $this->shortTag; + } + + public function setShortTag(?string $shortTag): static + { + $this->shortTag = $shortTag; + + return $this; + } + + public function nameAndShort(): string + { + return $this->name . ' (' . $this->shortTag . ')'; + } } diff --git a/src/Entity/Document.php b/src/Entity/Document.php index bec0f3f8..2d44deb7 100644 --- a/src/Entity/Document.php +++ b/src/Entity/Document.php @@ -4,12 +4,14 @@ namespace App\Entity; +use App\Doctrine\TimestampableTrait; use App\Repository\DocumentRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\Embedded; use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; use Symfony\Component\Uid\Uuid; @@ -19,18 +21,10 @@ * @SuppressWarnings(PHPMD.ExcessivePublicCount) */ #[ORM\Entity(repositoryClass: DocumentRepository::class)] -#[ORM\InheritanceType('SINGLE_TABLE')] -#[ORM\DiscriminatorColumn(name: 'class', type: 'string')] -#[ORM\DiscriminatorMap([ - self::CLASS_INVENTORY => Inventory::class, - self::CLASS_DOCUMENT => Document::class, - self::CLASS_DECISION => Decision::class, -])] -class Document +#[ORM\HasLifecycleCallbacks] +class Document implements EntityWithFileInfo { - public const CLASS_INVENTORY = 'inventory'; - public const CLASS_DOCUMENT = 'document'; - public const CLASS_DECISION = 'decision'; + use TimestampableTrait; #[ORM\Id] #[ORM\Column(type: 'uuid', unique: true, nullable: false)] @@ -38,21 +32,6 @@ class Document #[ORM\CustomIdGenerator(class: UuidGenerator::class)] private Uuid $id; - #[ORM\Column(nullable: false)] - private \DateTimeImmutable $createdAt; - - #[ORM\Column(nullable: false)] - private \DateTimeImmutable $updatedAt; - - #[ORM\Column(length: 100, nullable: true)] - private ?string $mimetype; - - #[ORM\Column(length: 1024, nullable: true)] - private ?string $filepath; - - #[ORM\Column(nullable: false)] - private int $filesize = 0; - // Number of pages for word based documents #[ORM\Column(nullable: false)] private int $pageCount = 0; @@ -71,23 +50,12 @@ class Document #[ORM\ManyToMany(targetEntity: Dossier::class, inversedBy: 'documents')] private Collection $dossiers; - /* The type of the local file on disk. This is mostly a PDF. These are the types that can be ingested by the workers */ - #[ORM\Column(length: 255, nullable: false)] - private string $fileType; - - /* The type of the original file. This could be a spreadsheet, word document or email */ - #[ORM\Column(length: 255, nullable: false)] - private string $sourceType; - #[ORM\Column(length: 255, nullable: false)] private string $documentNr; #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: false)] private \DateTimeInterface $documentDate; - #[ORM\Column(length: 255, nullable: false)] - private string $filename; - #[ORM\Column(nullable: true)] private ?int $familyId = null; @@ -97,8 +65,8 @@ class Document #[ORM\Column(nullable: true)] private ?int $threadId = null; - #[ORM\Column(length: 255, nullable: true)] - private ?string $judgement = null; + #[ORM\Column(length: 255, nullable: true, enumType: Judgement::class)] + private ?Judgement $judgement = null; /** @var array */ #[ORM\Column(type: Types::JSON, nullable: false)] @@ -111,28 +79,44 @@ class Document #[ORM\Column(length: 255, nullable: true)] private ?string $period = null; - #[ORM\Column(nullable: false)] - private bool $uploaded = false; - /** @var Collection|IngestLog[] */ #[ORM\OneToMany(mappedBy: 'document', targetEntity: IngestLog::class, orphanRemoval: true)] private Collection $ingestLogs; #[ORM\Column] - private bool $suspended; + private bool $suspended = false; #[ORM\Column] - private bool $withdrawn; + private bool $withdrawn = false; + + #[ORM\Column(length: 255, nullable: true, enumType: WithdrawReason::class)] + private ?WithdrawReason $withdrawReason = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $withdrawExplanation = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $withdrawDate = null; /** @var Collection|Inquiry[] */ #[ORM\ManyToMany(targetEntity: Inquiry::class, mappedBy: 'documents')] private Collection $inquiries; + #[Embedded(class: FileInfo::class, columnPrefix: 'file_')] + private FileInfo $fileInfo; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $link = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $remark = null; + public function __construct() { $this->dossiers = new ArrayCollection(); $this->ingestLogs = new ArrayCollection(); $this->inquiries = new ArrayCollection(); + $this->fileInfo = new FileInfo(); } public function getId(): Uuid @@ -145,66 +129,6 @@ public function setId(UUid $uuid): void $this->id = $uuid; } - public function getCreatedAt(): \DateTimeImmutable - { - return $this->createdAt; - } - - public function setCreatedAt(\DateTimeImmutable $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; - } - - public function getUpdatedAt(): \DateTimeImmutable - { - return $this->updatedAt; - } - - public function setUpdatedAt(\DateTimeImmutable $updatedAt): self - { - $this->updatedAt = $updatedAt; - - return $this; - } - - public function getMimetype(): ?string - { - return $this->mimetype; - } - - public function setMimetype(?string $mimetype): self - { - $this->mimetype = $mimetype; - - return $this; - } - - public function getFilepath(): ?string - { - return $this->filepath; - } - - public function setFilepath(?string $filepath): self - { - $this->filepath = $filepath; - - return $this; - } - - public function getFilesize(): int - { - return $this->filesize; - } - - public function setFilesize(int $filesize): self - { - $this->filesize = $filesize; - - return $this; - } - public function getPageCount(): int { return $this->pageCount; @@ -277,18 +201,6 @@ public function setDocumentDate(\DateTimeInterface $documentDate): self return $this; } - public function getFilename(): string - { - return $this->filename; - } - - public function setFilename(string $filename): self - { - $this->filename = $filename; - - return $this; - } - public function getFamilyId(): ?int { return $this->familyId; @@ -325,12 +237,12 @@ public function setThreadId(int $threadId): self return $this; } - public function getJudgement(): ?string + public function getJudgement(): ?Judgement { return $this->judgement; } - public function setJudgement(string $judgement): self + public function setJudgement(Judgement $judgement): self { $this->judgement = $judgement; @@ -389,18 +301,6 @@ public function setPeriod(string $period): self return $this; } - public function isUploaded(): bool - { - return $this->uploaded; - } - - public function setUploaded(bool $uploaded): self - { - $this->uploaded = $uploaded; - - return $this; - } - /** * @return Collection|Dossier[] */ @@ -486,77 +386,133 @@ public function groupedIngestLogs(): array return $grouped; } - public function getFileType(): ?string + public function isSuspended(): bool { - return $this->fileType; + return $this->suspended; } - public function setFileType(string $fileType): self + public function setSuspended(bool $suspended): self { - $this->fileType = $fileType; + $this->suspended = $suspended; return $this; } - public function getSourceType(): ?string + public function isWithdrawn(): bool + { + return $this->withdrawn; + } + + /** + * @return Collection|Inquiry[] + */ + public function getInquiries(): Collection + { + return $this->inquiries; + } + + public function addInquiry(Inquiry $inquiry): static { - return $this->sourceType; + if (! $this->inquiries->contains($inquiry)) { + $this->inquiries->add($inquiry); + } + + return $this; } - public function setSourceType(string $sourceType): self + public function removeInquiry(Inquiry $inquiry): static { - $this->sourceType = $sourceType; + if ($this->inquiries->removeElement($inquiry)) { + $inquiry->removeDocument($this); + } return $this; } - public function isSuspended(): bool + public function getFileInfo(): FileInfo { - return $this->suspended; + return $this->fileInfo; } - public function setSuspended(bool $suspended): self + public function setFileInfo(FileInfo $fileInfo): self { - $this->suspended = $suspended; + $this->fileInfo = $fileInfo; return $this; } - public function isWithdrawn(): bool + public function getLink(): ?string { - return $this->withdrawn; + return $this->link; } - public function setWithdrawn(bool $withdrawn): self + public function setLink(?string $link): static { - $this->withdrawn = $withdrawn; + $this->link = $link; return $this; } - /** - * @return Collection|Inquiry[] - */ - public function getInquiries(): Collection + public function isUploaded(): bool { - return $this->inquiries; + return $this->fileInfo->isUploaded(); } - public function addInquiry(Inquiry $inquiry): static + public function shouldBeUploaded(): bool { - if (! $this->inquiries->contains($inquiry)) { - $this->inquiries->add($inquiry); + if ($this->suspended === true) { + return false; } - return $this; + if (! $this->judgement) { + return false; + } + + return $this->judgement->isAtLeastPartialPublic(); } - public function removeInquiry(Inquiry $inquiry): static + public function getRemark(): ?string { - if ($this->inquiries->removeElement($inquiry)) { - $inquiry->removeDocument($this); - } + return $this->remark; + } + + public function setRemark(?string $remark): static + { + $this->remark = $remark; return $this; } + + public function getFileCacheKey(): string + { + return $this->documentNr; + } + + public function getWithdrawReason(): ?WithdrawReason + { + return $this->withdrawReason; + } + + public function getWithdrawExplanation(): ?string + { + return $this->withdrawExplanation; + } + + public function getWithdrawDate(): ?\DateTimeImmutable + { + return $this->withdrawDate; + } + + public function withdraw(WithdrawReason $reason, string $explanation): void + { + $this->withdrawn = true; + $this->withdrawReason = $reason; + $this->withdrawExplanation = $explanation; + $this->withdrawDate = new \DateTimeImmutable(); + + $this->fileInfo->setMimetype(null); + $this->fileInfo->setUploaded(false); + $this->fileInfo->setSize(0); + $this->fileInfo->setPath(null); + } } diff --git a/src/Entity/Dossier.php b/src/Entity/Dossier.php index 510ed881..559828fb 100644 --- a/src/Entity/Dossier.php +++ b/src/Entity/Dossier.php @@ -4,23 +4,30 @@ namespace App\Entity; +use App\Doctrine\TimestampableTrait; use App\Repository\DossierRepository; +use App\ValueObject\DossierUploadStatus; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Doctrine\Common\Collections\Criteria; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\JoinTable; use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Uid\Uuid; /** * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.ExcessivePublicCount) */ #[ORM\Entity(repositoryClass: DossierRepository::class)] +#[UniqueEntity('dossierNr')] +#[ORM\HasLifecycleCallbacks] class Dossier implements EntityWithId { + use TimestampableTrait; + public const STATUS_CONCEPT = 'concept'; // Dossier is just uploaded and does not have (all) the documents present yet public const STATUS_COMPLETED = 'completed'; // Dossier has all the uploaded documents and is ready for publication public const STATUS_PREVIEW = 'preview'; // Dossier is in preview mode and can only be viewed with specific tokens @@ -52,20 +59,14 @@ class Dossier implements EntityWithId #[ORM\Column(type: 'uuid', unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] - private Uuid $id; - - #[ORM\Column] - private \DateTimeImmutable $createdAt; - - #[ORM\Column] - private \DateTimeImmutable $updatedAt; + private ?Uuid $id = null; /** @var Collection|Document[] */ #[ORM\ManyToMany(targetEntity: Document::class, mappedBy: 'dossiers')] #[ORM\OrderBy(['documentNr' => 'ASC'])] private Collection $documents; - #[ORM\Column(length: 255)] + #[ORM\Column(length: 255, unique: true)] private string $dossierNr; #[ORM\Column(length: 500)] @@ -101,13 +102,28 @@ class Dossier implements EntityWithId private string $decision; /** @var Collection|Inquiry[] */ - #[ORM\ManyToMany(targetEntity: Inquiry::class, inversedBy: 'dossiers')] + #[ORM\ManyToMany(targetEntity: Inquiry::class, mappedBy: 'dossiers')] #[JoinTable(name: 'inquiry_dossier')] private Collection $inquiries; #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] private ?\DateTimeImmutable $publicationDate; + #[ORM\OneToOne(mappedBy: 'dossier', targetEntity: Inventory::class)] + private ?Inventory $inventory = null; + + #[ORM\OneToOne(mappedBy: 'dossier', targetEntity: RawInventory::class)] + private ?RawInventory $rawInventory = null; + + #[ORM\OneToOne(mappedBy: 'dossier', targetEntity: DecisionDocument::class)] + private ?DecisionDocument $decisionDocument = null; + + /** + * @var string[]|null + */ + #[ORM\Column(nullable: true)] + private ?array $defaultSubjects = null; + public function __construct() { $this->documents = new ArrayCollection(); @@ -116,35 +132,11 @@ public function __construct() $this->inquiries = new ArrayCollection(); } - public function getId(): Uuid + public function getId(): ?Uuid { return $this->id; } - public function getCreatedAt(): \DateTimeImmutable - { - return $this->createdAt; - } - - public function setCreatedAt(\DateTimeImmutable $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; - } - - public function getUpdatedAt(): \DateTimeImmutable - { - return $this->updatedAt; - } - - public function setUpdatedAt(\DateTimeImmutable $updatedAt): self - { - $this->updatedAt = $updatedAt; - - return $this; - } - public function getDossierNr(): string { return $this->dossierNr; @@ -189,12 +181,9 @@ public function setStatus(string $status): self return $this; } - public function uploadCount(): int + public function getUploadStatus(): DossierUploadStatus { - $crit = new Criteria(); - $crit->where(Criteria::expr()->eq('uploaded', true)); - - return $this->documents->matching($crit)->count(); + return new DossierUploadStatus($this); } /** @@ -245,14 +234,6 @@ public function removeGovernmentOfficial(GovernmentOfficial $governmentOfficial) return $this; } - public function isVisible(): bool - { - return - $this->status === self::STATUS_PUBLISHED - || $this->status === self::STATUS_PREVIEW - ; - } - public function getSummary(): string { return $this->summary; @@ -278,26 +259,23 @@ public function setDocumentPrefix(string $documentPrefix): self } /** - * When $allDocuments is true, it will return all documents, including inventory and decision documents, otherwise only - * "documents" are returned. - * * @return Collection|Document[] */ - public function getDocuments(bool $allDocuments = false): Collection + public function getDocuments(): Collection { - if ($allDocuments) { - return $this->documents; - } + return $this->documents; + } - // We should be able to use filter() here, but it doesn't work for phpstan (https://github.com/doctrine/collections/issues/364) - $documents = []; - foreach ($this->documents as $element) { - if (get_class($element) === Document::class) { - $documents[] = $element; - } - } + public function getInventory(): ?Inventory + { + return $this->inventory; + } + + public function setInventory(?Inventory $inventory): self + { + $this->inventory = $inventory; - return new ArrayCollection($documents); + return $this; } public function addDocument(Document $document): self @@ -413,4 +391,62 @@ public function setPublicationDate(?\DateTimeImmutable $publicationDate): void { $this->publicationDate = $publicationDate; } + + public function getDecisionDocument(): ?DecisionDocument + { + return $this->decisionDocument; + } + + public function setDecisionDocument(?DecisionDocument $decisionDocument): self + { + $this->decisionDocument = $decisionDocument; + + return $this; + } + + /** + * @return string[]|null + */ + public function getDefaultSubjects(): ?array + { + return $this->defaultSubjects; + } + + /** + * @param string[]|null $defaultSubjects + * + * @return $this + */ + public function setDefaultSubjects(?array $defaultSubjects): static + { + $this->defaultSubjects = $defaultSubjects; + + return $this; + } + + public function removeInquiry(Inquiry $inquiry): static + { + if ($this->inquiries->removeElement($inquiry)) { + $inquiry->removeDossier($this); + } + + return $this; + } + + public function getRawInventory(): ?RawInventory + { + return $this->rawInventory; + } + + public function setRawInventory(?RawInventory $rawInventory): static + { + // set the owning side of the relation if necessary + if ($rawInventory !== null && $rawInventory->getDossier() !== $this) { + $rawInventory->setDossier($this); + } + + $this->rawInventory = $rawInventory; + + return $this; + } } diff --git a/src/Entity/EntityWithId.php b/src/Entity/EntityWithId.php index f81ebc6c..65fb43b0 100644 --- a/src/Entity/EntityWithId.php +++ b/src/Entity/EntityWithId.php @@ -8,5 +8,5 @@ interface EntityWithId { - public function getId(): Uuid; + public function getId(): ?Uuid; } diff --git a/src/Entity/Inquiry.php b/src/Entity/Inquiry.php index da67e272..80090144 100644 --- a/src/Entity/Inquiry.php +++ b/src/Entity/Inquiry.php @@ -30,11 +30,11 @@ class Inquiry private \DateTimeImmutable $updatedAt; /** @var Collection|Document[] */ - #[ORM\ManyToMany(targetEntity: Document::class, inversedBy: 'inquiries')] + #[ORM\ManyToMany(targetEntity: Document::class, inversedBy: 'inquiries', cascade: ['persist'])] private Collection $documents; /** @var Collection|Dossier[] */ - #[ORM\ManyToMany(targetEntity: Dossier::class, mappedBy: 'inquiries')] + #[ORM\ManyToMany(targetEntity: Dossier::class, inversedBy: 'inquiries', cascade: ['persist'])] private Collection $dossiers; #[ORM\Column(length: 255)] diff --git a/src/Entity/Inventory.php b/src/Entity/Inventory.php index dfbaaf02..a1f9dfc9 100644 --- a/src/Entity/Inventory.php +++ b/src/Entity/Inventory.php @@ -4,9 +4,67 @@ namespace App\Entity; +use App\Doctrine\TimestampableTrait; use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\Embedded; +use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; +use Symfony\Component\Uid\Uuid; #[ORM\Entity] -class Inventory extends Document +#[ORM\HasLifecycleCallbacks] +class Inventory implements EntityWithFileInfo { + use TimestampableTrait; + + #[ORM\Id] + #[ORM\Column(type: 'uuid', unique: true, nullable: false)] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: UuidGenerator::class)] + private Uuid $id; + + #[ORM\OneToOne(inversedBy: 'inventory', targetEntity: Dossier::class)] + #[ORM\JoinColumn(name: 'dossier_id', referencedColumnName: 'id', nullable: false, onDelete: 'cascade')] + private Dossier $dossier; + + #[Embedded(class: FileInfo::class, columnPrefix: 'file_')] + private FileInfo $file; + + public function getId(): Uuid + { + return $this->id; + } + + public function __construct() + { + $this->file = new FileInfo(); + } + + public function setDossier(Dossier $dossier): self + { + $this->dossier = $dossier; + + return $this; + } + + public function getDossier(): Dossier + { + return $this->dossier; + } + + public function getFileInfo(): FileInfo + { + return $this->file; + } + + public function setFileInfo(FileInfo $fileInfo): self + { + $this->file = $fileInfo; + + return $this; + } + + public function getFileCacheKey(): string + { + return 'inventory-' . $this->id->toBase58(); + } } diff --git a/src/EventSubscriber/ChangePasswordSubscriber.php b/src/EventSubscriber/ChangePasswordSubscriber.php index 3c8a84e6..735a61f4 100644 --- a/src/EventSubscriber/ChangePasswordSubscriber.php +++ b/src/EventSubscriber/ChangePasswordSubscriber.php @@ -21,7 +21,7 @@ class ChangePasswordSubscriber implements EventSubscriberInterface protected UrlGeneratorInterface $urlGenerator; protected Security $security; - // Skip the redirector when we are on these routes + // Skip the redirector when we are on these routes, otherwise we end up in a redirect loop /** @var array|string[] */ protected array $skipRoutes = [ '2fa_check', diff --git a/src/EventSubscriber/PaginationCountSubscriber.php b/src/EventSubscriber/PaginationCountSubscriber.php index 07f38b39..a93f21c4 100644 --- a/src/EventSubscriber/PaginationCountSubscriber.php +++ b/src/EventSubscriber/PaginationCountSubscriber.php @@ -32,7 +32,7 @@ public function itemCount(ItemsEvent $event): void return; } - $event->count = $event->target->getDocumentCount(); + $event->count = $event->target->getResultCount(); $event->items = $event->target->getEntries(); $event->stopPropagation(); diff --git a/src/EventSubscriber/SecurityHeaderSubscriber.php b/src/EventSubscriber/SecurityHeaderSubscriber.php index f88ba145..d4ed5ece 100644 --- a/src/EventSubscriber/SecurityHeaderSubscriber.php +++ b/src/EventSubscriber/SecurityHeaderSubscriber.php @@ -5,6 +5,7 @@ namespace App\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -13,6 +14,13 @@ */ class SecurityHeaderSubscriber implements EventSubscriberInterface { + protected string $appMode; + + protected const CSP_SELF = "'self'"; + protected const CSP_UNSAFE_INLINE = "'unsafe-inline'"; + protected const CSP_UNSAFE_EVAL = "'unsafe-eval'"; + protected const CSP_DATA = 'data:'; + /** @var array|string[] */ protected array $fields = [ 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', @@ -29,12 +37,43 @@ class SecurityHeaderSubscriber implements EventSubscriberInterface 'X-Download-Options' => 'noopen', 'X-Permitted-Cross-Domain-Policies' => 'off', 'X-XSS-Protection' => '1; mode=block', - 'Content-Security-Policy' => "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " . - "style-src 'self' 'unsafe-inline'; " . - "img-src 'self' data: ; " . - "font-src 'self';", ]; + /** @var array|string[][][] */ + protected array $csp = [ + 'FRONTEND' => [ + 'script-src' => [self::CSP_SELF, 'https://statistiek.rijksoverheid.nl'], + 'style-src' => [self::CSP_SELF], + 'img-src' => [self::CSP_SELF, self::CSP_DATA, 'https://statistiek.rijksoverheid.nl'], + 'font-src' => [self::CSP_SELF], + ], + 'BALIE' => [ + 'script-src' => [self::CSP_SELF, self::CSP_UNSAFE_INLINE, self::CSP_UNSAFE_EVAL, 'https://statistiek.rijksoverheid.nl'], + 'style-src' => [self::CSP_SELF, self::CSP_UNSAFE_INLINE], + 'img-src' => [self::CSP_SELF, self::CSP_DATA, 'https://statistiek.rijksoverheid.nl'], + 'font-src' => [self::CSP_SELF], + ], + 'BOTH' => [ + 'script-src' => [self::CSP_SELF, self::CSP_UNSAFE_INLINE, self::CSP_UNSAFE_EVAL, 'https://statistiek.rijksoverheid.nl'], + 'style-src' => [self::CSP_SELF, self::CSP_UNSAFE_INLINE], + 'img-src' => [self::CSP_SELF, self::CSP_DATA], + 'font-src' => [self::CSP_SELF], + ], + ]; + + public function __construct(string $appMode) + { + $this->appMode = $appMode; + } + + public function onKernelRequest(RequestEvent $event): void + { + // Add random nonce that can be used in CSP for this request only + $nonce = bin2hex(random_bytes(16)); + + $event->getRequest()->attributes->set('csp_nonce', $nonce); + } + public function onKernelResponse(ResponseEvent $event): void { $response = $event->getResponse(); @@ -44,12 +83,35 @@ public function onKernelResponse(ResponseEvent $event): void $response->headers->set($key, $value); } } + + // Add nonce to CSP + $nonce = $event->getRequest()->attributes->get('csp_nonce'); + + $csp = $this->csp[$this->appMode] ?? $this->csp['both']; + $csp['script-src'][] = "'nonce-" . $nonce . "'"; + $csp['style-src'][] = "'nonce-" . $nonce . "'"; + + $response->headers->set('Content-Security-Policy', $this->buildCsp($csp)); } public static function getSubscribedEvents(): array { return [ + KernelEvents::REQUEST => 'onKernelRequest', KernelEvents::RESPONSE => 'onKernelResponse', ]; } + + /** + * @param string[][] $csp + */ + protected function buildCsp(array $csp): string + { + $result = []; + foreach ($csp as $key => $value) { + $result[] = $key . ' ' . join(' ', $value); + } + + return implode('; ', $result); + } } diff --git a/src/Exception/FixtureInventoryException.php b/src/Exception/FixtureInventoryException.php index eaa50f5b..12b0f347 100644 --- a/src/Exception/FixtureInventoryException.php +++ b/src/Exception/FixtureInventoryException.php @@ -7,7 +7,7 @@ class FixtureInventoryException extends \RuntimeException { /** - * @param array> $errors + * @param array> $errors */ public function __construct( string $message, @@ -15,7 +15,7 @@ public function __construct( ) { foreach ($errors as $error) { foreach ($error as $rowIndex => $errorDescription) { - $message .= "\n- [row $rowIndex] $errorDescription"; + $message .= "\n- [$rowIndex] $errorDescription"; } } @@ -23,7 +23,7 @@ public function __construct( } /** - * @param array> $errors + * @param array> $errors */ public static function forProcessingErrors(array $errors): self { diff --git a/src/Form/ChoiceTypeWithHelp.php b/src/Form/ChoiceTypeWithHelp.php index 9dd6b29b..d355fc99 100644 --- a/src/Form/ChoiceTypeWithHelp.php +++ b/src/Form/ChoiceTypeWithHelp.php @@ -24,7 +24,7 @@ public function configureOptions(OptionsResolver $resolver): void */ public function finishView(FormView $view, FormInterface $form, array $options): void { - parent::finishView($view, $form, $options); // TODO: Change the autogenerated stub + parent::finishView($view, $form, $options); foreach ($view->children as $child) { $child->vars['help'] = $options['choice_help_labels'][$child->vars['value']] ?? []; diff --git a/src/Form/DepartmentType.php b/src/Form/DepartmentType.php index c131e131..f965979f 100644 --- a/src/Form/DepartmentType.php +++ b/src/Form/DepartmentType.php @@ -8,6 +8,8 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; /** * @template-extends AbstractType @@ -20,10 +22,23 @@ class DepartmentType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $builder + ->add('short_tag', TextType::class, [ + 'label' => 'Shortname', + 'required' => true, + 'help' => 'Short name of the agency or ministry', + 'constraints' => [ + new NotBlank(), + new Length(['min' => 2, 'max' => 10]), + ], + ]) ->add('name', TextType::class, [ - 'label' => 'Naam', + 'label' => 'Name', 'required' => true, - 'help' => 'Naam van ministerie of organisatie', + 'help' => 'Name of the agency or ministry', + 'constraints' => [ + new NotBlank(), + new Length(['min' => 2, 'max' => 100]), + ], ]) ->add('submit', SubmitType::class, [ 'label' => 'Opslaan', diff --git a/src/Form/Document/IngestFormType.php b/src/Form/Document/IngestFormType.php index 8eb1c2c9..e6fd05ec 100644 --- a/src/Form/Document/IngestFormType.php +++ b/src/Form/Document/IngestFormType.php @@ -25,7 +25,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'mapped' => false, ]) ->add('submit', SubmitType::class, [ - 'label' => 'Ingest document', + 'label' => 'Ingest dossier', 'attr' => [ 'class' => 'btn btn-primary', ], diff --git a/src/Form/Dossier/DossierType.php b/src/Form/Dossier/DossierType.php index f8226424..695f2f19 100644 --- a/src/Form/Dossier/DossierType.php +++ b/src/Form/Dossier/DossierType.php @@ -10,7 +10,9 @@ use App\Entity\GovernmentOfficial; use App\Form\Transformer\DocumentPrefixTransformer; use App\Form\Transformer\EntityToArrayTransformer; +use App\Form\Transformer\TextToArrayTransformer; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -20,9 +22,13 @@ use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\ReversedTransformer; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\File; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; /** * @template-extends AbstractType @@ -33,13 +39,17 @@ class DossierType extends AbstractType { protected EntityManagerInterface $doctrine; - protected const ACCEPTED_MIMETYPES = [ + protected const SPREADSHEET_MIMETYPES = [ 'application/xls', 'application/vnd.ms-excel', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]; + protected const DOCUMENT_MIMETYPES = [ + 'application/pdf', + ]; + public function __construct(EntityManagerInterface $doctrine) { $this->doctrine = $doctrine; @@ -47,20 +57,33 @@ public function __construct(EntityManagerInterface $doctrine) /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder + ->add('dossier_nr', TextType::class, [ + 'label' => 'Dossier nummer', + 'required' => true, + 'help' => 'Verplicht dossier nummer. Let op: het dossier nummer moet uniek zijn en kan na aanmaken niet meer gewijzigd worden.', + 'attr' => ['class' => 'w-full'], + 'constraints' => [ + new NotBlank(), + new Length(['min' => 3, 'max' => 255]), + ], + ]) ->add('title', TextType::class, [ 'label' => 'Titel', 'required' => true, 'help' => 'Geef een korte titel voor het dossier', + 'attr' => ['class' => 'w-full'], ]) - ->add('summary', TextAreaType::class, [ + ->add('summary', TextareaType::class, [ 'label' => 'Omschrijving', 'required' => true, 'help' => 'Geef een korte omschrijving voor het dossier', 'constraints' => [], + 'attr' => ['class' => 'w-full'], ]) ->add('departments', EntityType::class, [ 'class' => Department::class, @@ -68,7 +91,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'multiple' => false, 'help' => 'Het departement waar het dossier onder hoort', - 'choice_label' => 'name', + 'choice_label' => 'name_and_short', + 'query_builder' => function (EntityRepository $er) { + return $er->createQueryBuilder('d') + ->orderBy('d.name', 'ASC'); + }, ]) ->add('governmentofficials', EntityType::class, [ 'class' => GovernmentOfficial::class, @@ -83,7 +110,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_label' => 'prefix', 'label' => 'Document prefix', 'required' => true, - 'help' => 'Het document prefix bepaald onder welk domein de documenten van dit dossier vallen', + 'help' => 'Het document prefix bepaalt onder welk domein de documenten van dit dossier vallen', 'placeholder' => 'Selecteer een prefix', ]) ->add('publication_reason', ChoiceType::class, [ @@ -91,23 +118,40 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => true, 'help' => 'De reden waarom dit dossier gepubliceerd wordt', 'choices' => [ - 'Wob: verzoek' => Dossier::REASON_WOB_REQUEST, - 'Woo: verzoek' => Dossier::REASON_WOO_REQUEST, - 'Woo: actieve openbaarmaking' => Dossier::REASON_WOO_ACTIVE, + 'Wob-verzoek' => Dossier::REASON_WOB_REQUEST, + 'Woo-verzoek' => Dossier::REASON_WOO_REQUEST, + 'Woo-actieve openbaarmaking' => Dossier::REASON_WOO_ACTIVE, ], ]) ->add('decision', ChoiceType::class, [ - 'label' => 'Genomen besluit', + 'label' => 'Soort besluit', 'required' => true, 'help' => 'Het besluit omtrent dit dossier', 'choices' => [ 'Reeds openbaar' => Dossier::DECISION_ALREADY_PUBLIC, 'Openbaar' => Dossier::DECISION_PUBLIC, - 'Gedeeltelijk openbaar' => Dossier::DECISION_PARTIAL_PUBLIC, + 'Deels openbaar' => Dossier::DECISION_PARTIAL_PUBLIC, 'Niet openbaar' => Dossier::DECISION_NOT_PUBLIC, 'Niets aangetroffen' => Dossier::DECISION_NOTHING_FOUND, ], ]) + ->add('decision_document', FileType::class, [ + 'label' => 'Besluit document', + 'required' => false, + 'help' => 'Het document met een motivatie van het besluit', + 'mapped' => false, + 'constraints' => [ + new File([ + 'mimeTypes' => self::DOCUMENT_MIMETYPES, + 'mimeTypesMessage' => 'Please upload a valid decision document (pdf)', + ]), + ], + ]) + ->add('default_subjects', TextType::class, [ + 'label' => 'Default subject', + 'required' => false, + 'help' => 'Onderwerp dat standaard aan documenten binnen dit dossier worden toegevoegd indien er geen onderwerp is meegeven', + ]) ->add('date_from', DateType::class, [ 'label' => 'Periode van', 'required' => false, @@ -131,7 +175,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'constraints' => [ new File([ 'maxSize' => '1024k', - 'mimeTypes' => self::ACCEPTED_MIMETYPES, + 'mimeTypes' => self::SPREADSHEET_MIMETYPES, 'mimeTypesMessage' => 'Please upload a valid spreadsheet', ]), ], @@ -141,10 +185,23 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]); $this->addTransformers($builder); + + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { + /** @var Dossier|null $dossier */ + $dossier = $event->getData(); + $form = $event->getForm(); + + if ($dossier && $dossier->getId() !== null) { + $form->remove('dossier_nr'); + } + }); } protected function addTransformers(FormBuilderInterface $builder): void { + // Default subjects is a text field, but holds semicolon separated files + $builder->get('default_subjects')->addModelTransformer(new ReversedTransformer(new TextToArrayTransformer(';')), forceAppend: true); + // If we are editing an entity, we need to transform the entity to an array if the choice is not multiple. This is because the dossier // entity always expects an array of entities, even if the choice is not multiple. if ($builder->get('departments')->getOption('multiple') == false) { @@ -162,7 +219,7 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Dossier::class, - 'edit_mode' => false, // Set to true if we are editting an entity. + 'edit_mode' => false, // Set to true if we are editing an entity. ]); } } diff --git a/src/Form/Dossier/SearchFormType.php b/src/Form/Dossier/SearchFormType.php index 875b649e..69904d9f 100644 --- a/src/Form/Dossier/SearchFormType.php +++ b/src/Form/Dossier/SearchFormType.php @@ -10,7 +10,6 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; /** @@ -43,12 +42,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'expanded' => true, 'multiple' => true, ]) - ->add('searchterm', TextType::class, [ - 'required' => false, - ]) + ->add('submit', SubmitType::class, [ 'attr' => [ - 'class' => 'btn btn-outline-dark btn-rounded waves-effect', + 'class' => 'icon icon-search', ], ]) ->setMethod('GET') diff --git a/src/Form/GovernmentOfficialType.php b/src/Form/GovernmentOfficialType.php index 046bb7a6..c42b2fb5 100644 --- a/src/Form/GovernmentOfficialType.php +++ b/src/Form/GovernmentOfficialType.php @@ -23,7 +23,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('name', TextType::class, [ 'label' => 'Naam', 'required' => true, - 'help' => 'Naam van bewindvoerder', + 'help' => 'Naam van bewindspersoon', ]) ->add('submit', SubmitType::class, [ 'label' => 'Opslaan', diff --git a/src/Form/User/UserCreateFormType.php b/src/Form/User/UserCreateFormType.php index fea2959e..0a472494 100644 --- a/src/Form/User/UserCreateFormType.php +++ b/src/Form/User/UserCreateFormType.php @@ -25,20 +25,20 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('name', TextType::class, [ - 'label' => 'Naam', + 'label' => 'Name', ]) ->add('email', TextType::class, [ - 'label' => 'E-mailadres', + 'label' => 'E-mail address', ]) ->add('roles', ChoiceTypeWithHelp::class, [ 'choices' => $this->createChoices(Roles::roleDetails()), 'choice_help_labels' => $this->createHelp(Roles::roleDetails()), 'multiple' => true, 'expanded' => true, - 'label' => 'Rollen', + 'label' => 'Roles', ]) ->add('submit', SubmitType::class, [ - 'label' => 'Gebruiker aanmaken', + 'label' => 'Create user', ]) ; diff --git a/src/Form/User/UserRoleFormType.php b/src/Form/User/UserRoleFormType.php index 93824c80..e28bb3c3 100644 --- a/src/Form/User/UserRoleFormType.php +++ b/src/Form/User/UserRoleFormType.php @@ -30,7 +30,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'expanded' => true, ]) ->add('submit', SubmitType::class, [ - 'label' => 'Opslaan', + 'label' => 'Save', ]) ; diff --git a/src/Message/ProcessDocumentMessage.php b/src/Message/ProcessDocumentMessage.php index 84cdcd02..6ba07027 100644 --- a/src/Message/ProcessDocumentMessage.php +++ b/src/Message/ProcessDocumentMessage.php @@ -8,8 +8,7 @@ class ProcessDocumentMessage { - // @todo: Rename to $dossierUuid, because this suggest the document uuid - protected Uuid $uuid; + protected Uuid $dossierUuid; protected string $remotePath; protected bool $chunked; protected string $chunkUuid; @@ -17,14 +16,14 @@ class ProcessDocumentMessage protected string $originalFilename; public function __construct( - Uuid $uuid, + Uuid $dossierUuid, string $remotePath, string $originalFilename, bool $chunked = false, string $chunkUuid = '', int $chunkCount = 0 ) { - $this->uuid = $uuid; + $this->dossierUuid = $dossierUuid; $this->remotePath = $remotePath; $this->chunked = $chunked; $this->chunkUuid = $chunkUuid; @@ -32,9 +31,9 @@ public function __construct( $this->originalFilename = $originalFilename; } - public function getUuid(): Uuid + public function getDossierUuid(): Uuid { - return $this->uuid; + return $this->dossierUuid; } public function isChunked(): bool diff --git a/src/MessageHandler/GenerateArchiveHandler.php b/src/MessageHandler/GenerateArchiveHandler.php index f64a86cf..770a957d 100644 --- a/src/MessageHandler/GenerateArchiveHandler.php +++ b/src/MessageHandler/GenerateArchiveHandler.php @@ -11,6 +11,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * This handler will process a generate archive message. Its task is to generate a ZIP archive file for the given documents in the message. + */ #[AsMessageHandler] class GenerateArchiveHandler { diff --git a/src/MessageHandler/IngestAudioHandler.php b/src/MessageHandler/IngestAudioHandler.php index a5a2c09c..01f18f95 100644 --- a/src/MessageHandler/IngestAudioHandler.php +++ b/src/MessageHandler/IngestAudioHandler.php @@ -11,6 +11,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * Ingest an audio file into the system. + */ #[AsMessageHandler] class IngestAudioHandler { diff --git a/src/MessageHandler/IngestDossiersHandler.php b/src/MessageHandler/IngestDossiersHandler.php index 0250e614..a64734df 100644 --- a/src/MessageHandler/IngestDossiersHandler.php +++ b/src/MessageHandler/IngestDossiersHandler.php @@ -13,6 +13,9 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\MessageBusInterface; +/** + * Ingest multiple dossiers that are publishable into the system. Used primarily for reindexing. + */ #[AsMessageHandler] class IngestDossiersHandler { diff --git a/src/MessageHandler/IngestPdfHandler.php b/src/MessageHandler/IngestPdfHandler.php index 1e6e60e5..36d34dc4 100644 --- a/src/MessageHandler/IngestPdfHandler.php +++ b/src/MessageHandler/IngestPdfHandler.php @@ -14,6 +14,9 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\MessageBusInterface; +/** + * Ingest a PDF file into the system. It will extract all pages fromm the pdf and emits a message for each page. + */ #[AsMessageHandler] class IngestPdfHandler { diff --git a/src/MessageHandler/IngestPdfPageHandler.php b/src/MessageHandler/IngestPdfPageHandler.php index 2e82f100..9688c2e0 100644 --- a/src/MessageHandler/IngestPdfPageHandler.php +++ b/src/MessageHandler/IngestPdfPageHandler.php @@ -11,6 +11,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * Ingest a single PDF page into the system. + */ #[AsMessageHandler] class IngestPdfPageHandler { diff --git a/src/MessageHandler/InitializeElasticRolloverHandler.php b/src/MessageHandler/InitializeElasticRolloverHandler.php index 26a52164..e46f4591 100644 --- a/src/MessageHandler/InitializeElasticRolloverHandler.php +++ b/src/MessageHandler/InitializeElasticRolloverHandler.php @@ -12,6 +12,9 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\MessageBusInterface; +/** + * Initialize the elasticsearch rollover and does a dossier ingestion of all dossiers. + */ #[AsMessageHandler] class InitializeElasticRolloverHandler { diff --git a/src/MessageHandler/ProcessDocumentHandler.php b/src/MessageHandler/ProcessDocumentHandler.php index 30428efa..fa4ea364 100644 --- a/src/MessageHandler/ProcessDocumentHandler.php +++ b/src/MessageHandler/ProcessDocumentHandler.php @@ -6,39 +6,33 @@ use App\Entity\Dossier; use App\Message\ProcessDocumentMessage; -use App\Service\DocumentService; +use App\Service\FileProcessService; use App\Service\Storage\DocumentStorageService; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * Process a document (archive, pdf) that is uploaded to the system. If the upload has been chunked, it will be stitched together first. + */ #[AsMessageHandler] class ProcessDocumentHandler { - protected EntityManagerInterface $doctrine; - protected LoggerInterface $logger; - protected DocumentService $documentService; - protected DocumentStorageService $storageService; - public function __construct( - DocumentService $documentService, - DocumentStorageService $storageService, - EntityManagerInterface $doctrine, - LoggerInterface $logger + private readonly FileProcessService $fileProcessService, + private readonly DocumentStorageService $storageService, + private readonly EntityManagerInterface $doctrine, + private readonly LoggerInterface $logger ) { - $this->documentService = $documentService; - $this->storageService = $storageService; - $this->doctrine = $doctrine; - $this->logger = $logger; } public function __invoke(ProcessDocumentMessage $message): void { - $dossier = $this->doctrine->getRepository(Dossier::class)->find($message->getUuid()); + $dossier = $this->doctrine->getRepository(Dossier::class)->find($message->getDossierUuid()); if (! $dossier) { // No dossier found for this message $this->logger->warning('No dossier found for this message', [ - 'dossier_uuid' => $message->getUuid(), + 'dossier_uuid' => $message->getDossierUuid(), ]); return; @@ -49,7 +43,7 @@ public function __invoke(ProcessDocumentMessage $message): void $localFile = $this->assembleChunks($message->getChunkUuid(), $message->getChunkCount()); if (! $localFile) { $this->logger->error('Could not assemble chunks', [ - 'dossier_uuid' => $message->getUuid(), + 'dossier_uuid' => $message->getDossierUuid(), 'chunk_uuid' => $message->getChunkUuid(), 'chunk_count' => $message->getChunkCount(), ]); @@ -57,7 +51,7 @@ public function __invoke(ProcessDocumentMessage $message): void return; } - $this->documentService->processDocument($localFile, $dossier, $message->getOriginalFilename()); + $this->fileProcessService->processFile($localFile, $dossier, $message->getOriginalFilename()); unlink($localFile->getPathname()); return; @@ -67,7 +61,7 @@ public function __invoke(ProcessDocumentMessage $message): void $localFilePath = $this->storageService->download($message->getRemotePath()); if (! $localFilePath) { $this->logger->error('File could not be downloaded', [ - 'dossier_uuid' => $message->getUuid(), + 'dossier_uuid' => $message->getDossierUuid(), 'file_path' => $message->getRemotePath(), ]); @@ -75,23 +69,28 @@ public function __invoke(ProcessDocumentMessage $message): void } $localFile = new \SplFileObject($localFilePath); - $this->documentService->processDocument($localFile, $dossier, $message->getOriginalFilename()); - $this->storageService->removeDownload($localFilePath); + try { + $this->fileProcessService->processFile($localFile, $dossier, $message->getOriginalFilename()); + } catch (\Throwable $e) { + throw $e; + } finally { + $this->storageService->removeDownload($localFilePath, true); + } } - protected function assembleChunks(string $uuid, int $chunkCount): ?\SplFileInfo + protected function assembleChunks(string $chunkUuid, int $chunkCount): ?\SplFileInfo { - $path = sprintf('%s/assembled-%s', sys_get_temp_dir(), $uuid); + $path = sprintf('%s/assembled-%s', sys_get_temp_dir(), $chunkUuid); $stitchedFile = new \SplFileObject($path, 'w'); for ($i = 0; $i < $chunkCount; $i++) { // Check if the chunk exists - $remoteChunkPath = '/uploads/chunks/' . $uuid . '/' . $i; + $remoteChunkPath = '/uploads/chunks/' . $chunkUuid . '/' . $i; $localChunkFile = $this->storageService->download($remoteChunkPath); if (! $localChunkFile) { $this->logger->error('Chunk is not readable', [ - 'uuid' => $uuid, + 'uuid' => $chunkUuid, 'chunk' => $i, ]); diff --git a/src/MessageHandler/SetElasticAliasHandler.php b/src/MessageHandler/SetElasticAliasHandler.php index d5a39ed2..9e3fb7c5 100644 --- a/src/MessageHandler/SetElasticAliasHandler.php +++ b/src/MessageHandler/SetElasticAliasHandler.php @@ -10,6 +10,9 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\MessageBusInterface; +/** + * Set the elasticsearch alias to the given index. + */ #[AsMessageHandler] class SetElasticAliasHandler { diff --git a/src/MessageHandler/UpdateDepartmentHandler.php b/src/MessageHandler/UpdateDepartmentHandler.php index dc6db976..789a7c1f 100644 --- a/src/MessageHandler/UpdateDepartmentHandler.php +++ b/src/MessageHandler/UpdateDepartmentHandler.php @@ -10,6 +10,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * Update a department in elasticsearch. + */ #[AsMessageHandler] class UpdateDepartmentHandler { diff --git a/src/MessageHandler/UpdateDossierHandler.php b/src/MessageHandler/UpdateDossierHandler.php index 73a9c926..4a9505c6 100644 --- a/src/MessageHandler/UpdateDossierHandler.php +++ b/src/MessageHandler/UpdateDossierHandler.php @@ -11,6 +11,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * Update a dossier data based on info in the database into elasticsearch. + */ #[AsMessageHandler] class UpdateDossierHandler { diff --git a/src/MessageHandler/UpdateOfficialHandler.php b/src/MessageHandler/UpdateOfficialHandler.php index babe37ab..ccfd1be4 100644 --- a/src/MessageHandler/UpdateOfficialHandler.php +++ b/src/MessageHandler/UpdateOfficialHandler.php @@ -10,6 +10,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +/** + * Update an official in elasticsearch. + */ #[AsMessageHandler] class UpdateOfficialHandler { diff --git a/src/Repository/BatchDownloadRepository.php b/src/Repository/BatchDownloadRepository.php index aac99c74..2405688d 100644 --- a/src/Repository/BatchDownloadRepository.php +++ b/src/Repository/BatchDownloadRepository.php @@ -77,4 +77,14 @@ public function findExpiredArchives(): array ->getQuery() ->getResult(); } + + public function pruneExpired(): void + { + $this->createQueryBuilder('b') + ->delete() + ->andWhere('b.expiration < :now') + ->setParameter('now', new \DateTimeImmutable()) + ->getQuery() + ->execute(); + } } diff --git a/src/Repository/DocumentRepository.php b/src/Repository/DocumentRepository.php index 5f9b513a..d1059d75 100644 --- a/src/Repository/DocumentRepository.php +++ b/src/Repository/DocumentRepository.php @@ -5,8 +5,10 @@ namespace App\Repository; use App\Entity\Document; +use App\Entity\Dossier; use App\Service\Elastic\Model\DocumentCounts; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Persistence\ManagerRegistry; /** @@ -42,30 +44,49 @@ public function remove(Document $entity, bool $flush = false): void } } - // /** - // * @return Document[] Returns an array of Document objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('s') - // ->andWhere('s.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('s.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } - - // public function findOneBySomeField($value): ?Document - // { - // return $this->createQueryBuilder('s') - // ->andWhere('s.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + /** + * @return Document[] + */ + public function findByThreadId(int $threadId, bool $onlyPublished = true): array + { + $qb = $this->createQueryBuilder('d') + ->innerJoin('d.dossiers', 'ds') + ->where('d.threadId = :threadId') + ->orderBy('d.documentDate', 'ASC') + ->setParameter('threadId', $threadId) + ; + + if ($onlyPublished) { + $qb + ->andWhere('ds.status = :status') + ->setParameter('status', Dossier::STATUS_PUBLISHED) + ; + } + + return $qb->getQuery()->getResult(); + } + + /** + * @return Document[] + */ + public function findByFamilyId(int $familyId, bool $onlyPublished = true): array + { + $qb = $this->createQueryBuilder('d') + ->innerJoin('d.dossiers', 'ds') + ->where('d.familyId = :familyId') + ->orderBy('d.documentDate', 'ASC') + ->setParameter('familyId', $familyId) + ; + + if ($onlyPublished) { + $qb + ->andWhere('ds.status = :status') + ->setParameter('status', Dossier::STATUS_PUBLISHED) + ; + } + + return $qb->getQuery()->getResult(); + } public function pagecount(): int { @@ -88,8 +109,6 @@ public function getCountAndPageSumForStatuses(array $dossierStatuses = []): Docu ->addSelect('SUM(d.pageCount) as totalPageCount') ->innerJoin('d.dossiers', 'ds') ->where($qb->expr()->in('ds.status', ':statuses')) - ->andWhere('d NOT INSTANCE OF App\Entity\Inventory') - ->andWhere('d NOT INSTANCE OF App\Entity\Decision') ->setParameters([ 'statuses' => $dossierStatuses, ]); @@ -103,26 +122,70 @@ public function getCountAndPageSumForStatuses(array $dossierStatuses = []): Docu ); } - // /** - // * @return Document[] - // */ - // public function findLatests(int $limit): array - // { - // return $this->createQueryBuilder('d') - // ->orderBy('d.createdAt', 'DESC') - // ->setMaxResults($limit) - // ->getQuery() - // ->getResult(); - // } - - // public function findByDossierAndDocument(string $dossierId, string $documentId) - // { - // return $this->createQueryBuilder('d') - // ->andWhere(':dossierId MEMBER OF d.dossiers') - // ->andWhere('d.id = :documentId') - // ->setParameter('dossierId', $dossierId) - // ->setParameter('documentId', $documentId) - // ->getQuery() - // ->getOneOrNullResult(); - // } + public function getRelatedDocumentsByThread(Document $document): ArrayCollection + { + $threadId = $document->getThreadId(); + if ($threadId < 1) { + return new ArrayCollection(); + } + + $threadDocuments = new ArrayCollection( + $this->findByThreadId($threadId) + ); + + return $threadDocuments->filter( + fn (Document $threadDocument): bool => $threadDocument->getId() !== $document->getId() + ); + } + + public function getRelatedDocumentsByFamily(Document $document): ArrayCollection + { + $familyId = $document->getFamilyId(); + if ($familyId < 1) { + return new ArrayCollection(); + } + + $familyDocuments = new ArrayCollection( + $this->findByFamilyId($familyId) + ); + + return $familyDocuments->filter( + fn (Document $familyDocument): bool => $familyDocument->getId() !== $document->getId() + ); + } + + /** + * @return Document[] + */ + public function findBySearchTerm(string $searchTerm, int $limit): array + { + $qb = $this->createQueryBuilder('d') + ->innerJoin('d.dossiers', 'ds') + ->leftJoin('d.inquiries', 'i') + ->where('d.fileInfo.name LIKE :searchTerm') + ->orWhere('d.documentNr LIKE :searchTerm') + ->orWhere('i.casenr LIKE :searchTerm') + ->orderBy('d.updatedAt', 'DESC') + ->setMaxResults($limit) + ->setParameter('searchTerm', '%' . $searchTerm . '%') + ; + + return $qb->getQuery()->getResult(); + } + + public function findOneByDossierAndDocumentId(Dossier $dossier, string $documentId): ?Document + { + $qb = $this->createQueryBuilder('d') + ->innerJoin('d.dossiers', 'ds') + ->where('d.documentId = :documentId') + ->andWhere('ds.id = :dossierId') + ->setParameter('documentId', $documentId) + ->setParameter('dossierId', $dossier->getId()) + ; + + /** @var ?Document $document */ + $document = $qb->getQuery()->getOneOrNullResult(); + + return $document; + } } diff --git a/src/Repository/DossierRepository.php b/src/Repository/DossierRepository.php index 86c40a6c..05311d5a 100644 --- a/src/Repository/DossierRepository.php +++ b/src/Repository/DossierRepository.php @@ -58,13 +58,21 @@ public function findAllPublishable(): array return $dossiers; } - // public function findOneBySomeField($value): ?Dossier - // { - // return $this->createQueryBuilder('s') - // ->andWhere('s.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + /** + * @return Dossier[] + */ + public function findBySearchTerm(string $searchTerm, int $limit): array + { + $qb = $this->createQueryBuilder('d') + ->leftJoin('d.inquiries', 'i') + ->where('d.title LIKE :searchTerm') + ->orWhere('d.dossierNr LIKE :searchTerm') + ->orWhere('i.casenr LIKE :searchTerm') + ->orderBy('d.updatedAt', 'DESC') + ->setMaxResults($limit) + ->setParameter('searchTerm', '%' . $searchTerm . '%') + ; + + return $qb->getQuery()->getResult(); + } } diff --git a/src/Roles.php b/src/Roles.php index 56c7c4e8..1e1fcecf 100644 --- a/src/Roles.php +++ b/src/Roles.php @@ -6,6 +6,7 @@ class Roles { + public const ROLE_SUPER_ADMIN = 'ROLE_SUPER_ADMIN'; public const ROLE_ADMIN = 'ROLE_ADMIN'; public const ROLE_ADMIN_USERS = 'ROLE_ADMIN_USERS'; public const ROLE_ADMIN_DOSSIERS = 'ROLE_ADMIN_DOSSIERS'; @@ -13,6 +14,11 @@ class Roles /** @var array|array{role: string, description: string, help: string}[] */ protected static array $roleInfo = [ + [ + 'role' => self::ROLE_SUPER_ADMIN, + 'description' => 'Super administrator', + 'help' => 'This user is allowed system wide operations.', + ], [ 'role' => self::ROLE_ADMIN, 'description' => 'Global administrator', @@ -36,6 +42,8 @@ class Roles ]; /** + * Returns a list of all role details that can be used in the administration system. + * * @return array{role: string, description: string, help: string}[] */ public static function roleDetails(): array diff --git a/src/Service/ArchiveService.php b/src/Service/ArchiveService.php index a89fdea8..b471f11b 100644 --- a/src/Service/ArchiveService.php +++ b/src/Service/ArchiveService.php @@ -38,6 +38,9 @@ public function __construct( /** * Generates a ZIP archive for the given batch download. Returns true on success (or already created), false otherwise. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ public function generateArchive(BatchDownload $batch): bool { @@ -51,11 +54,19 @@ public function generateArchive(BatchDownload $batch): bool $zip = new \ZipArchive(); $zip->open($zipArchivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $documents = $batch->getDocuments(); + if (count($documents) === 0) { + foreach ($batch->getDossier()->getDocuments() as $document) { + $documents[] = $document->getDocumentNr(); + } + } + // Add all document files - foreach ($batch->getDocuments() as $documentNr) { + $localPaths = []; + foreach ($documents as $documentNr) { // Check (again) if the document exists in the dossier. $document = $this->findInDossier($batch->getDossier(), $documentNr); - if (! $document) { + if (! $document || ! $document->isUploaded()) { continue; } @@ -69,20 +80,41 @@ public function generateArchive(BatchDownload $batch): bool continue; } - $zip->addFile($localPath, $document->getDocumentNr() . '-' . $document->getFilename()); - $this->storageService->removeDownload($localPath); + + // Generate correct filename for this document + $fileName = $document->getDocumentNr() . '-' . $document->getFileInfo()->getName(); + if (! str_ends_with(strtolower($fileName), '.pdf')) { + $fileName .= '.pdf'; + } + + $sanitizer = new FilenameSanitizer($fileName); + $sanitizer->stripAdditionalCharacters(); + $sanitizer->stripIllegalFilesystemCharacters(); + $sanitizer->stripRiskyCharacters(); + $fileName = $sanitizer->getFilename(); + + $zip->addFile($localPath, $fileName); + + $localPaths[] = $localPath; } // Finished processing $zip->close(); - $destinationPath = sprintf('batch-%s.zip', $batch->getId()->toBase58()); - if ($this->saveZip($zipArchivePath, $destinationPath, $batch)) { + // Remove all local files + foreach ($localPaths as $localPath) { + $this->storageService->removeDownload($localPath); + } + + if ($this->saveZip($zipArchivePath, $batch->getFilename(), $batch)) { $batch->setStatus(BatchDownload::STATUS_COMPLETED); } else { $batch->setStatus(BatchDownload::STATUS_FAILED); } + // Store the documents in the batch + $batch->setDocuments($documents); + // Save new status and size $fileSize = filesize($zipArchivePath); $batch->setSize(is_int($fileSize) ? strval($fileSize) : '0'); // size == bigint == string @@ -140,4 +172,51 @@ protected function saveZip(string $zipArchivePath, string $destinationPath, Batc return true; } + + /** + * @return false|resource + */ + public function getZipStream(BatchDownload $batch) + { + try { + return $this->storage->readStream($batch->getFilename()); + } catch (FilesystemException $e) { + $this->logger->error('Failed open ZIP archive ', [ + 'batch' => $batch->getId()->toBase58(), + 'path' => $batch->getFilename(), + 'exception' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * @param string[] $documents + */ + public function archiveExists(Dossier $dossier, array $documents): ?BatchDownload + { + // No documents mean all documents of the dossier + if (count($documents) === 0) { + foreach ($dossier->getDocuments() as $document) { + $documents[] = $document->getDocumentNr(); + } + } + + // Prune all expired documents (garbage collection in case cron doesn't work) + $this->doctrine->getRepository(BatchDownload::class)->pruneExpired(); + + $batches = $this->doctrine->getRepository(BatchDownload::class)->findBy([ + 'status' => BatchDownload::STATUS_COMPLETED, + 'dossier' => $dossier, + ]); + + foreach ($batches as $batch) { + if ($batch->getDocuments() === $documents) { + return $batch; + } + } + + return null; + } } diff --git a/src/Service/DocumentService.php b/src/Service/DocumentService.php index a3db778f..ea44adb7 100644 --- a/src/Service/DocumentService.php +++ b/src/Service/DocumentService.php @@ -6,130 +6,51 @@ use App\Entity\Document; use App\Entity\Dossier; +use App\Entity\WithdrawReason; +use App\Service\Ingest\IngestLogger; +use App\Service\Ingest\IngestService; +use App\Service\Ingest\Options; use App\Service\Storage\DocumentStorageService; +use App\Service\Storage\ThumbnailStorageService; use Doctrine\ORM\EntityManagerInterface; -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\File\File; +use Symfony\Contracts\Translation\TranslatorInterface; /** - * This class will manage documents that are uploaded to the system. It can process either a PDF file and add it to a dossier, or a ZIP where - * it will find PDFs and add them to the dossier. Note that only PDFs are added when the filename of the PDF matches a document number in - * the dossier. + * This class handles Document entity management. Not to be confused with 'ES documents' or 'upload document' (files)! */ class DocumentService { public function __construct( private readonly EntityManagerInterface $doctrine, - private readonly DocumentStorageService $storage, - private readonly LoggerInterface $logger, + private readonly IngestLogger $ingestLogger, + private readonly TranslatorInterface $translator, + private readonly IngestService $ingester, + private readonly DocumentStorageService $documentStorage, + private readonly ThumbnailStorageService $thumbStorage, ) { } - public function processDocument(\SplFileInfo $file, Dossier $dossier, string $originalFile): bool + public function withdraw(Document $document, WithdrawReason $reason, string $explanation): void { - $parts = pathinfo($originalFile); - $ext = $parts['extension'] ?? ''; + $this->removeAllFilesForDocument($document); - switch ($ext) { - case 'mp3': - return $this->processFile($file, $dossier, $originalFile, 'audio'); - case 'zip': - return $this->processZip($file, $dossier); - case 'pdf': - return $this->processFile($file, $dossier, $originalFile, 'pdf'); - default: - $this->logger->error('Unsupported filetype detected', [ - 'extension' => $ext, - 'originalFile' => $originalFile, - 'dossierId' => $dossier->getId(), - ]); - throw new \RuntimeException('Unsupported filetype detected'); - } - } - - protected function processFile(\SplFileInfo $file, Dossier $dossier, string $originalFile, string $type): bool - { - // Fetch document number from the beginning of the filename. Only use digits - $originalFile = basename($originalFile); - preg_match('/^(\d+)/', $originalFile, $matches); - $documentId = $matches[1] ?? null; - - if (is_null($documentId)) { - $this->logger->error('Cannot extract document ID from the filename', [ - 'filename' => $originalFile, - 'matches' => $matches, - 'dossierId' => $dossier->getId(), - ]); - - throw new \RuntimeException('Cannot extract document id from file'); - } - - $documentNr = $dossier->getDocumentPrefix() . '-' . $documentId; - - // Find matching document entity in the database - $document = $this->doctrine->getRepository(Document::class)->findOneBy(['documentNr' => $documentNr]); - - if (! $document || $document->getDossiers()->contains($dossier) === false) { - $this->logger->error("Document with id $documentId not found", [ - 'documentId' => $documentId, - 'dossierId' => $dossier->getId(), - ]); - - throw new \RuntimeException("Document with id $documentId not found"); - } - - // Store document in storage - if (! $this->storage->storeDocument($file, $document)) { - $this->logger->error('Failed to store document', [ - 'documentId' => $documentId, - 'path' => $file->getRealPath(), - ]); - - throw new \RuntimeException("Failed to store document with id $documentId"); - } - - $document->setFileType($type); + $document->withdraw($reason, $explanation); $this->doctrine->persist($document); $this->doctrine->flush(); - return true; - } - - protected function processZip(\SplFileInfo $file, Dossier $dossier): bool - { - $zip = new \ZipArchive(); - $zip->open($file->getPathname()); - - for ($i = 0; $i != $zip->numFiles; $i++) { - $filename = $zip->getNameIndex($i); - if (! $filename) { - continue; - } - $ext = pathinfo($filename, PATHINFO_EXTENSION); - if ($ext != 'pdf') { - continue; - } - - // Extract file to tmp dir - $zip->extractTo(sys_get_temp_dir(), $filename); - - try { - $tmpPath = sprintf('%s/%s', sys_get_temp_dir(), $filename); - $this->processFile(new File($tmpPath), $dossier, $filename, 'pdf'); - } catch (\Exception) { - // do nothing. Seems like an extra file in the zip - } - - // Cleanup tmp file if needed - if (file_exists($tmpPath)) { - unlink($tmpPath); - } - } - - $zip->close(); - - return true; + // Re-ingest the document, this will update all file metadata and overwrite (with an empty set) any existing page content. + $this->ingester->ingest($document, new Options()); + + $this->ingestLogger->success( + $document, + 'withdraw', + sprintf( + 'Withdrawn with reason %s. Explanation: %s', + $this->translator->trans($reason->value), + $explanation + ) + ); } public function removeDocumentFromDossier(Dossier $dossier, Document $document): void @@ -141,11 +62,19 @@ public function removeDocumentFromDossier(Dossier $dossier, Document $document): $dossier->removeDocument($document); if ($document->getDossiers()->count() === 0) { - // Remove whole document as there are no links left + // Remove whole document including all files, as there are no links left. + $this->removeAllFilesForDocument($document); $this->doctrine->remove($document); } $this->doctrine->persist($dossier); $this->doctrine->flush(); } + + private function removeAllFilesForDocument(Document $document): void + { + $this->documentStorage->deleteAllFilesForDocument($document); + + $this->thumbStorage->deleteAllThumbsForDocument($document); + } } diff --git a/src/Service/DossierService.php b/src/Service/DossierService.php index 8627465a..5e7203b3 100644 --- a/src/Service/DossierService.php +++ b/src/Service/DossierService.php @@ -4,8 +4,16 @@ namespace App\Service; +use App\Entity\DecisionDocument; +use App\Entity\Document; use App\Entity\Dossier; +use App\Message\IngestDossierMessage; +use App\Message\RemoveDossierMessage; use App\Message\UpdateDossierMessage; +use App\Service\Inventory\InventoryService; +use App\Service\Inventory\ProcessInventoryResult; +use App\Service\Storage\DocumentStorageService; +use App\SourceType; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -13,147 +21,133 @@ /** * This class handles dossier management. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class DossierService { - protected EntityManagerInterface $doctrine; - protected InventoryService $inventoryService; - protected LoggerInterface $logger; - protected MessageBusInterface $messageBus; - public function __construct( - EntityManagerInterface $doctrine, - InventoryService $inventoryService, - MessageBusInterface $messageBus, - LoggerInterface $logger + private readonly EntityManagerInterface $doctrine, + private readonly InventoryService $inventoryService, + private readonly MessageBusInterface $messageBus, + private readonly LoggerInterface $logger, + private readonly InquiryService $inquiryService, + private readonly DocumentStorageService $documentStorage, ) { - $this->doctrine = $doctrine; - $this->inventoryService = $inventoryService; - $this->logger = $logger; - $this->messageBus = $messageBus; } /** - * Creates a new dossier with inventory file. Returns an array of errors, if any. - * - * There can be multiple errors per row/line number. Note that line number 0 means - * a generic error - * - * [ - * 0 => [ - * "incorrect column count", - * ] - * 4 => [ - * "invalid date format", - * "invalid other error", - * ] - * ] - * - * @return array + * Creates a new dossier with an inventory file and decision document. */ - public function create(Dossier $dossier, UploadedFile $file = null): array - { - $dossier->setCreatedAt(new \DateTimeImmutable()); - $dossier->setUpdatedAt(new \DateTimeImmutable()); + public function create( + Dossier $dossier, + ?UploadedFile $inventoryUpload, + ?UploadedFile $decisionUpload + ): ProcessInventoryResult { $dossier->setStatus(Dossier::STATUS_CONCEPT); - // @TODO: Hardcoded dossier prefix! - $dossierNr = 'VWS-' . random_int(100, 999) . '-' . random_int(1000, 9999); - $dossier->setDossierNr($dossierNr); + $this->doctrine->persist($dossier); + if ($dossier->getId() === null) { + $this->logger->error('Dossier has an empty ID. This should not happen'); - // Wrap in transaction, so we can rollback if inventory processing fails - $this->doctrine->beginTransaction(); + return new ProcessInventoryResult(); + } - $this->doctrine->persist($dossier); + if ($decisionUpload instanceof UploadedFile) { + $this->storeDecisionDocument($decisionUpload, $dossier); + } + + // Wrap in transaction, so we can roll back if inventory processing fails + $this->doctrine->beginTransaction(); $this->doctrine->flush(); - if ($file) { - $errors = $this->inventoryService->processInventory($file, $dossier); - } else { - $errors = []; - } + $result = $this->inventoryService->processInventory($inventoryUpload, $dossier); + if ($result->isSuccessful()) { + // Commit inventory and dossier changes + $this->doctrine->commit(); - if (count($errors)) { + if ($dossier->getId()) { + $this->messageBus->dispatch(new UpdateDossierMessage($dossier->getId())); + } + + $this->logger->info('Dossier created', [ + 'dossier' => $dossier->getId(), + ]); + } else { // Rollback inventory and dossier changes $this->doctrine->rollback(); $this->logger->info('Dossier creation failed', [ 'dossier' => $dossier->getId(), - 'errors' => $errors, + 'errors' => $result->getAllErrors(), ]); - - return $errors; } - // Commit inventory and dossier changes - $this->doctrine->commit(); - - $this->logger->info('Dossier created', [ - 'dossier' => $dossier->getId(), - ]); - - return []; + return $result; } - /** - * @return array - */ - public function update(Dossier $dossier, UploadedFile $file = null): array - { - $dossier->setUpdatedAt(new \DateTimeImmutable()); + public function update( + Dossier $dossier, + ?UploadedFile $inventoryUpload, + ?UploadedFile $decisionUpload + ): ProcessInventoryResult { + if ($decisionUpload instanceof UploadedFile) { + $this->storeDecisionDocument($decisionUpload, $dossier); + } // Wrap in transaction $this->doctrine->beginTransaction(); - $this->doctrine->persist($dossier); $this->doctrine->flush(); - $errors = []; - if ($file) { - $errors = $this->inventoryService->processInventory($file, $dossier); + if ($dossier->getId() === null) { + return new ProcessInventoryResult(); } - if ($errors) { + if ($inventoryUpload instanceof UploadedFile) { + $result = $this->inventoryService->processInventory($inventoryUpload, $dossier); + } else { + $result = new ProcessInventoryResult(); + } + + if ($result->isSuccessful()) { + // Commit inventory and dossier changes + $this->doctrine->commit(); + + $this->messageBus->dispatch(new UpdateDossierMessage($dossier->getId())); + + $this->logger->info('Dossier updated', [ + 'dossier' => $dossier->getId(), + ]); + } else { // Rollback everything mutated in this transaction $this->doctrine->rollback(); $this->logger->info('Dossier update failed', [ 'dossier' => $dossier->getId(), - 'errors' => $errors, + 'errors' => $result->getAllErrors(), ]); - - return $errors; } - // Commit inventory and dossier changes - $this->doctrine->commit(); - - $this->messageBus->dispatch(new UpdateDossierMessage($dossier->getId())); - - $this->logger->info('Dossier updated', [ - 'dossier' => $dossier->getId(), - ]); - - return []; + return $result; } public function remove(Dossier $dossier): void { - // Remove documents that are only attached to this dossier - foreach ($dossier->getDocuments() as $document) { - if ($document->getDossiers()->count() == 1) { - $this->doctrine->remove($document); - } + if ($dossier->getId() === null) { + return; } - // @TODO: remove from elasticsearch - - $this->doctrine->remove($dossier); - $this->doctrine->flush(); + // Remove from elasticsearch + $this->messageBus->dispatch(new RemoveDossierMessage($dossier->getId())); } public function changeState(Dossier $dossier, string $newState): void { + if ($dossier->getId() === null) { + return; + } + if (! $dossier->isAllowedState($newState)) { $this->logger->error('Invalid state change', [ 'dossier' => $dossier->getId(), @@ -169,7 +163,7 @@ public function changeState(Dossier $dossier, string $newState): void case Dossier::STATUS_COMPLETED: // Check all documents present foreach ($dossier->getDocuments() as $document) { - if (! $document->isUploaded()) { + if ($document->shouldBeUploaded() && ! $document->isUploaded()) { $this->logger->error('Invalid state change', [ 'dossier' => $dossier->getId(), 'oldState' => $dossier->getStatus(), @@ -180,6 +174,11 @@ public function changeState(Dossier $dossier, string $newState): void throw new \InvalidArgumentException('Not all documents are uploaded in this dossier'); } } + + if ($dossier->getDecisionDocument()?->getFileInfo()->isUploaded() !== true) { + throw new \InvalidArgumentException('Decision document is missing'); + } + break; } @@ -195,4 +194,86 @@ public function changeState(Dossier $dossier, string $newState): void 'newState' => $newState, ]); } + + /** + * Store the decision document to disk and add it to the dossier. + */ + protected function storeDecisionDocument(UploadedFile $upload, Dossier $dossier): void + { + if ($dossier->getId() === null) { + return; + } + + $decisionDocument = $dossier->getDecisionDocument(); + if (! $decisionDocument) { + // Create inventory if not exists yet + $decisionDocument = new DecisionDocument(); + $dossier->setDecisionDocument($decisionDocument); + $decisionDocument->setDossier($dossier); + } + + $file = $decisionDocument->getFileInfo(); + $file->setSourceType(SourceType::SOURCE_PDF); + $file->setType('pdf'); + + // Set original filename + $filename = 'decision-' . $dossier->getDossierNr() . '.' . $upload->getClientOriginalExtension(); + $file->setName($filename); + + $this->doctrine->persist($decisionDocument); + + if (! $this->documentStorage->storeDocument($upload, $decisionDocument)) { + throw new \RuntimeException('Could not store decision document'); + } + } + + public function dispatchIngest(Dossier $dossier): void + { + if ($dossier->getId() === null) { + return; + } + + $message = new IngestDossierMessage($dossier->getId()); + $this->messageBus->dispatch($message); + } + + // Returns true when the dossier (and/or document) is allowed to be viewed. This will also + // consider documents and dossiers which are marked as preview and that are allowed by the session. + public function isViewingAllowed(Dossier $dossier, Document $document = null): bool + { + // If dossier is published, allow viewing + if ($dossier->getStatus() == Dossier::STATUS_PUBLISHED) { + return true; + } + + // If dossier is not preview, deny access + if ($dossier->getStatus() != Dossier::STATUS_PREVIEW) { + return false; + } + + $inquiryIds = $this->inquiryService->getInquiries(); + + // Check if any inquiry id from the dossier is in the session inquiry ids. + foreach ($dossier->getInquiries() as $inquiry) { + if (in_array($inquiry->getId(), $inquiryIds)) { + // Inquiry id is set in the session, so allow viewing + return true; + } + } + + // If document is not visible, and no document is given, deny viewing + if (! $document) { + return false; + } + + // Check all inquiry ids from the document to see if we have one matching in our session. + foreach ($document->getInquiries() as $inquiry) { + if (in_array($inquiry->getId(), $inquiryIds)) { + // Inquiry id is set in the session, so allow viewing + return true; + } + } + + return false; + } } diff --git a/src/Service/Elastic/ElasticClientFactory.php b/src/Service/Elastic/ElasticClientFactory.php index ded0aa1c..12e06948 100644 --- a/src/Service/Elastic/ElasticClientFactory.php +++ b/src/Service/Elastic/ElasticClientFactory.php @@ -6,6 +6,7 @@ use Elastic\Elasticsearch\ClientBuilder; use Elastic\Elasticsearch\ClientInterface; +use GuzzleHttp\Client; /** * Creates a configured Elasticsearch client. @@ -21,6 +22,10 @@ public static function create( string $mtlsCAPath = null ): ClientInterface { $builder = new ClientBuilder(); + $builder->setHttpClient(new Client([ + 'timeout' => 15, + 'connect_timeout' => 5, + ])); $builder->setHosts(explode(',', $host)); if (! empty($username)) { diff --git a/src/Service/Elastic/ElasticService.php b/src/Service/Elastic/ElasticService.php index 8b29baaa..0d6cbb4b 100644 --- a/src/Service/Elastic/ElasticService.php +++ b/src/Service/Elastic/ElasticService.php @@ -21,6 +21,7 @@ * Service for interacting with Elasticsearch. Together with the SearchService, this should be the only entrypoint to elasticsearch. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class ElasticService { @@ -43,15 +44,13 @@ public function __construct(ElasticClientInterface $elastic, LoggerInterface $lo public function updatePage(Document $document, int $pageNr, string $content): void { $this->logger->debug('[Elasticsearch][Index Page] Inserting page'); - - for ($retryCount = 0; $retryCount <= self::$maxRetries; $retryCount++) { - try { - $this->elastic->update([ - 'index' => ElasticConfig::WRITE_INDEX, - 'id' => $document->getDocumentNr(), - 'body' => [ - 'script' => [ - 'source' => <<< EOF + $this->retry(function () use ($document, $pageNr, $content) { + $this->elastic->update([ + 'index' => ElasticConfig::WRITE_INDEX, + 'id' => $document->getDocumentNr(), + 'body' => [ + 'script' => [ + 'source' => <<< EOF if (ctx._source.pages == null) { ctx._source.pages = [params.page]; } else { @@ -68,41 +67,17 @@ public function updatePage(Document $document, int $pageNr, string $content): vo } } EOF, - 'lang' => 'painless', - 'params' => [ - 'page' => [ - 'page_nr' => $pageNr, - 'content' => $content, - ], + 'lang' => 'painless', + 'params' => [ + 'page' => [ + 'page_nr' => $pageNr, + 'content' => $content, ], ], ], - ]); - - return; - } catch (ClientResponseException $e) { - if ($retryCount == self::$maxRetries) { - $this->logger->error('[Elasticsearch][Index Page] Too many retries', [ - 'message' => $e->getMessage(), - 'code' => $e->getCode(), - ]); - throw $e; - } - if ($e->getCode() != 409) { - $this->logger->error('[Elasticsearch][Index Page] An error occurred: {message}', [ - 'message' => $e->getMessage(), - 'code' => $e->getCode(), - ]); - throw $e; - } - - $waitMs = (int) ceil(min(100000 * pow(1.5, $retryCount), 5000000)); - $this->logger->notice('[Elasticsearch][Index Page] Update document version mismatch. Retrying...', [ - 'waitMs' => $waitMs, - ]); - usleep($waitMs); - } - } + ], + ]); + }); } /** @@ -121,23 +96,24 @@ public function updateDocument(Document $document, array $metadata = [], array $ $inquiryIds[] = $inquiry->getId(); } + $file = $document->getFileInfo(); $documentDoc = [ 'type' => 'document', 'document_nr' => $document->getDocumentNr(), 'dossier_nr' => $dossierIds, - 'mime_type' => $document->getMimeType(), - 'file_size' => $document->getFileSize(), - 'file_type' => $document->getFileType(), - 'source_type' => $document->getSourceType(), + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'file_type' => $file->getType(), + 'source_type' => $file->getSourceType(), 'date' => $document->getDocumentDate()->format(\DateTimeInterface::ATOM), - 'filename' => $document->getFilename(), + 'filename' => $file->getName(), 'family_id' => $document->getFamilyId() ?? 0, 'document_id' => $document->getDocumentId() ?? 0, 'thread_id' => $document->getThreadId() ?? 0, 'judgement' => $document->getJudgement(), 'grounds' => $document->getGrounds(), 'subjects' => $document->getSubjects(), - 'period' => $document->getPeriod(), + 'date_period' => $document->getPeriod(), 'audio_duration' => $document->getDuration(), 'document_pages' => $document->getPageCount(), 'dossiers' => $dossiers, @@ -229,6 +205,22 @@ public function updateDossier(Dossier $dossier, bool $updateDocuments = true): v } } + public function updateDossierDecisionContent(Dossier $dossier, string $content): void + { + $dossierDoc = [ + 'decision_content' => $content, + ]; + + $this->elastic->update([ + 'index' => ElasticConfig::WRITE_INDEX, + 'id' => $dossier->getDossierNr(), + 'body' => [ + 'doc' => $dossierDoc, + 'doc_as_upsert' => true, + ], + ]); + } + public function updateAudio(Document $document, Metadata $metadata): void { $ids = []; @@ -257,11 +249,11 @@ public function updateAudio(Document $document, Metadata $metadata): void ]); } - public function documentExists(Document $document): bool + public function documentExists(string $documentNr): bool { $result = $this->elastic->exists([ 'index' => ElasticConfig::WRITE_INDEX, - 'id' => $document->getDocumentNr(), + 'id' => $documentNr, ]); /** @var Elasticsearch $result */ @@ -341,7 +333,7 @@ public function updateOfficial(GovernmentOfficial $old, GovernmentOfficial $new) } } } - + if (ctx._source.dossiers != null) { for (int i = 0; i < ctx._source.dossiers.length; i++) { if (ctx._source.dossiers[i].government_official != null) { @@ -352,7 +344,7 @@ public function updateOfficial(GovernmentOfficial $old, GovernmentOfficial $new) } } } - } + } EOF, 'lang' => 'painless', 'params' => [ @@ -393,7 +385,7 @@ public function updateDepartment(Department $old, Department $new): void } } } - + if (ctx._source.dossiers != null) { for (int i = 0; i < ctx._source.dossiers.length; i++) { if (ctx._source.dossiers[i].departments != null) { @@ -404,7 +396,7 @@ public function updateDepartment(Department $old, Department $new): void } } } - } + } EOF, 'lang' => 'painless', 'params' => [ @@ -464,46 +456,120 @@ public function getLogger(): LoggerInterface private function updateAllDocumentsForDossier(Dossier $dossier, array $dossierDoc): void { $this->logger->debug('[Elasticsearch][Update Dossier] Updating dossier in document'); - for ($retryCount = 0; $retryCount <= self::$maxRetries; $retryCount++) { - try { - $this->elastic->updateByQuery([ - 'index' => ElasticConfig::WRITE_INDEX, - 'body' => [ - 'query' => [ - 'bool' => [ - 'must' => [ - ['match' => ['type' => Config::TYPE_DOCUMENT]], - ['match' => ['dossier_nr' => $dossier->getDossierNr()]], - ], + $this->retry(function () use ($dossier, $dossierDoc) { + $this->elastic->updateByQuery([ + 'index' => ElasticConfig::WRITE_INDEX, + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['match' => ['type' => Config::TYPE_DOCUMENT]], + ['match' => ['dossier_nr' => $dossier->getDossierNr()]], ], ], - 'script' => [ - 'source' => <<< EOF - for (int i = 0; i < ctx._source.dossiers.length; i++) { - if (ctx._source.dossiers[i].dossier_nr == params.dossier.dossier_nr) { - ctx._source.dossiers[i] = params.dossier; - } + ], + 'script' => [ + 'source' => <<< EOF + for (int i = 0; i < ctx._source.dossiers.length; i++) { + if (ctx._source.dossiers[i].dossier_nr == params.dossier.dossier_nr) { + ctx._source.dossiers[i] = params.dossier; } + } EOF, - 'lang' => 'painless', - 'params' => [ - 'dossier' => $dossierDoc, + 'lang' => 'painless', + 'params' => [ + 'dossier' => $dossierDoc, + ], + ], + ], + ]); + }); + } + + // Removes the nested dossier entry from all documents that have this dossier. Note that this can + // leave orphaned documents (documents that do not have any dossiers as nested entities). We assume + // that these are cleaned up BEFORE running this function. + private function removeAllDocumentsForDossier(Dossier $dossier): void + { + $this->retry(function () use ($dossier) { + $this->elastic->updateByQuery([ + 'index' => ElasticConfig::WRITE_INDEX, + 'body' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['match' => ['type' => Config::TYPE_DOCUMENT]], + ['match' => ['dossier_nr' => $dossier->getDossierNr()]], ], ], ], - ]); + 'script' => [ + 'source' => <<< EOF + for (int i = ctx._source.dossiers.length-1; i>=0; i-) { + if (ctx._source.dossiers[i].dossier_nr == params.dossier_nr) { + ctx._source.dossiers.remove(i); + } + } +EOF, + 'lang' => 'painless', + 'params' => [ + 'dossier_nr' => $dossier->getDossierNr(), + ], + ], + ], + ]); + }); + } + + // Removes a given document + public function removeDocument(string $documentNr): void + { + if (! $this->documentExists($documentNr)) { + return; + } + + // @Note: it's possible that the document is removed in between checking for existence and deleting. + + // Delete document + $this->elastic->delete([ + 'index' => ElasticConfig::WRITE_INDEX, + 'id' => $documentNr, + ]); + } + + // Removes a dossier and all references inside documents that have this dossier as nested object. + public function removeDossier(Dossier $dossier): void + { + // Remove all dossier entries found in documents + $this->removeAllDocumentsForDossier($dossier); + + // Delete dossier document + $this->elastic->delete([ + 'index' => ElasticConfig::WRITE_INDEX, + 'id' => $dossier->getDossierNr(), + ]); + } + + // Will retry a callable for a specified number of times. If the callable throws a ClientResponseException with a 409 code, it will + // retry the callable. If the callable throws a ClientResponseException with a different code, it will throw the exception. + // If the callable throws any other exception, it will throw the exception. + protected function retry(callable $fn): void + { + for ($retryCount = 0; $retryCount <= self::$maxRetries; $retryCount++) { + try { + $fn(); return; } catch (ClientResponseException $e) { if ($retryCount == self::$maxRetries) { - $this->logger->error('[Elasticsearch][Update Dossier] Too many retries', [ + $this->logger->error('[Elasticsearch] Too many retries', [ 'message' => $e->getMessage(), 'code' => $e->getCode(), ]); throw $e; } if ($e->getCode() != 409) { - $this->logger->error('[Elasticsearch][Update Dossier] An error occurred: {message}', [ + $this->logger->error('[Elasticsearch] An error occurred: {message}', [ 'message' => $e->getMessage(), 'code' => $e->getCode(), ]); @@ -511,7 +577,7 @@ private function updateAllDocumentsForDossier(Dossier $dossier, array $dossierDo } $waitMs = (int) ceil(min(100000 * pow(1.4, $retryCount), 5000000)); - $this->logger->notice('[Elasticsearch][Update Dossier] Update dossier version mismatch. Retrying...', [ + $this->logger->notice('[Elasticsearch] Update dossier version mismatch. Retrying...', [ 'waitMs' => $waitMs, ]); usleep($waitMs); diff --git a/src/Service/Elastic/IndexService.php b/src/Service/Elastic/IndexService.php index 7eb824f7..6fa4e3d1 100644 --- a/src/Service/Elastic/IndexService.php +++ b/src/Service/Elastic/IndexService.php @@ -150,18 +150,24 @@ public function find(string $name = null): array $params['index'] = $name; } - /** @var Elasticsearch $response */ - $response = $this->elastic->cat()->indices($params); + /** @var Elasticsearch $indicesResponse */ + $indicesResponse = $this->elastic->cat()->indices($params); + + /** @var Elasticsearch $mappingResponse */ + $mappingResponse = $this->elastic->indices()->getMapping(); + $mappingData = $mappingResponse->asArray(); $indices = []; - foreach ($response->asArray() as $index) { + foreach ($indicesResponse->asArray() as $index) { $indexAliases = array_keys($aliases[$index['index']]['aliases'] ?? []); + $indices[] = new Index( name: $index['index'], health: $index['health'], status: $index['status'], docsCount: $index['docs.count'] ?? '??', storeSize: $index['store.size'] ?? '??', + mappingVersion: strval($mappingData[$index['index']]['mappings']['_meta']['version'] ?? 'unknown'), aliases: $indexAliases, ); } diff --git a/src/Service/Elastic/Model/Index.php b/src/Service/Elastic/Model/Index.php index 5b7ec1cf..d18aad5e 100644 --- a/src/Service/Elastic/Model/Index.php +++ b/src/Service/Elastic/Model/Index.php @@ -15,6 +15,7 @@ public function __construct( public readonly string $status, public readonly string $docsCount, public readonly string $storeSize, + public readonly string $mappingVersion, public readonly array $aliases = [], ) { } diff --git a/src/Service/FakeDataGenerator.php b/src/Service/FakeDataGenerator.php index 13b92272..daf3fa72 100644 --- a/src/Service/FakeDataGenerator.php +++ b/src/Service/FakeDataGenerator.php @@ -8,6 +8,7 @@ use App\Entity\Document; use App\Entity\Dossier; use App\Entity\GovernmentOfficial; +use App\Entity\Judgement; use App\SourceType; use Doctrine\ORM\EntityManagerInterface; use Faker\Factory; @@ -64,8 +65,6 @@ public function generateDossier(string $dossierNr): Dossier ]); $dossier = new Dossier(); - $dossier->setCreatedAt(new \DateTimeImmutable()); - $dossier->setUpdatedAt(new \DateTimeImmutable()); $dossier->setDossierNr($dossierNr); $dossier->setTitle($this->faker->sentence()); $dossier->setSummary($sentences); @@ -99,24 +98,48 @@ public function generateDocument(): Document $documentId = random_int(100000, 999999); $documentNr = 'PREF-' . $documentId; $document = new Document(); - $document->setCreatedAt(new \DateTimeImmutable()); - $document->setUpdatedAt(new \DateTimeImmutable()); $document->setDocumentDate(new \DateTimeImmutable()); $document->setDocumentNr($documentNr); - $document->setSourceType($sourceType); $document->setDuration(0); $document->setFamilyId($documentId); $document->setDocumentid($documentId); $document->setThreadId(0); $document->setPageCount(random_int(1, 20)); $document->setSummary('summary of the document'); - $document->setUploaded(false); - $document->setFilename('document-' . $documentNr . '.pdf'); - $document->setMimetype('application/pdf'); - $document->setFileType('pdf'); $document->setSubjects($this->generateSubjects()); - $document->setSuspended(false); - $document->setWithdrawn(false); + + $file = $document->getFileInfo(); + $file->setSourceType($sourceType); + $file->setName('document-' . $documentNr . '.pdf'); + $file->setMimetype('application/pdf'); + $file->setType('pdf'); + + switch ($randomInt = random_int(0, 10)) { + case $randomInt <= 5: + $document->setJudgement(Judgement::PUBLIC); + $file->setUploaded(true); + break; + case $randomInt <= 7: + $document->setJudgement(Judgement::PARTIAL_PUBLIC); + $file->setUploaded(true); + break; + case $randomInt <= 8: + $document->setJudgement(Judgement::ALREADY_PUBLIC); + $file->setUploaded(false); + break; + default: + $document->setJudgement(Judgement::NOT_PUBLIC); + $file->setUploaded(false); + break; + } + + if (random_int(0, 1) === 1) { + $document->setLink($this->faker->url()); + } + + if (random_int(0, 1) === 1) { + $document->setRemark($this->faker->text()); + } return $document; } diff --git a/src/Service/FileUploader.php b/src/Service/FileUploader.php index 948afd2b..4162292c 100644 --- a/src/Service/FileUploader.php +++ b/src/Service/FileUploader.php @@ -8,6 +8,7 @@ use App\Form\Dossier\DocumentUploadType; use App\Message\ProcessDocumentMessage; use App\Service\Storage\DocumentStorageService; +use Psr\Log\LoggerInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -22,6 +23,7 @@ class FileUploader protected MessageBusInterface $messageBus; protected FormFactoryInterface $formFactory; protected DocumentStorageService $documentStorage; + protected LoggerInterface $logger; /** @var array|string[] */ protected array $mandatoryParams = [ @@ -32,11 +34,16 @@ class FileUploader 'dzuuid', ]; - public function __construct(MessageBusInterface $messageBus, FormFactoryInterface $formFactory, DocumentStorageService $documentStorage) - { + public function __construct( + MessageBusInterface $messageBus, + FormFactoryInterface $formFactory, + DocumentStorageService $documentStorage, + LoggerInterface $logger, + ) { $this->messageBus = $messageBus; $this->formFactory = $formFactory; $this->documentStorage = $documentStorage; + $this->logger = $logger; } /** @@ -55,6 +62,10 @@ public function handleUpload(Request $request, Dossier $dossier): bool protected function handleCompleteFiles(Request $request, Dossier $dossier): bool { + if ($dossier->getId() == null) { + return false; + } + // Check if document uploaded is valid (e.g. not too large). This is done through the validation of // a form (@TODO: we should use direct validation for this, not through a form) $form = $this->formFactory->create(DocumentUploadType::class, $dossier, ['csrf_protection' => false]); @@ -66,15 +77,21 @@ protected function handleCompleteFiles(Request $request, Dossier $dossier): bool // Dispatch message for each file uploaded to process the files /** @var UploadedFile[] $uploadedFiles */ $uploadedFiles = $request->files->get('document_upload'); - // $uploadedFiles = $form->get('upload')->getData(); foreach ($uploadedFiles as $uploadedFile) { + $this->logger->info('uploaded document file', [ + 'path' => $uploadedFile->getRealPath(), + 'original_file' => $uploadedFile->getClientOriginalName(), + 'size' => $uploadedFile->getSize(), + 'file_hash' => hash_file('sha256', $uploadedFile->getRealPath()), + ]); + $remotePath = '/uploads/' . (string) $dossier->getId() . '/' . $uploadedFile->getClientOriginalName(); if (! $this->documentStorage->store($uploadedFile, $remotePath)) { continue; } $message = new ProcessDocumentMessage( - uuid: $dossier->getId(), + dossierUuid: $dossier->getId(), remotePath: $remotePath, originalFilename: $uploadedFile->getClientOriginalName(), chunked: false, @@ -94,6 +111,10 @@ protected function handleCompleteFiles(Request $request, Dossier $dossier): bool */ protected function handleChunkedUpload(Request $request, Dossier $dossier): bool { + if ($dossier->getId() == null) { + return false; + } + foreach ($this->mandatoryParams as $param) { if (! $request->request->has($param)) { throw new \Exception('Missing parameter: ' . $param); @@ -110,6 +131,15 @@ protected function handleChunkedUpload(Request $request, Dossier $dossier): bool $upload = $request->files->get('document_upload'); /** @var UploadedFile $uploadedFile */ $uploadedFile = $upload['upload']; + + $this->logger->info('uploaded document chunk file', [ + 'path' => $uploadedFile->getRealPath(), + 'original_file' => $uploadedFile->getClientOriginalName(), + 'size' => $uploadedFile->getSize(), + 'chunk_index' => $chunkIndex, + 'file_hash' => hash_file('sha256', $uploadedFile->getRealPath()), + ]); + $this->documentStorage->store($uploadedFile, $remoteChunkFile); // Check if all parts have been uploaded (ie: all chunks are found in the chunk upload dir) @@ -121,7 +151,7 @@ protected function handleChunkedUpload(Request $request, Dossier $dossier): bool // Dispatch a message to process the uploaded file $message = new ProcessDocumentMessage( - uuid: $dossier->getId(), + dossierUuid: $dossier->getId(), remotePath: $remoteChunkPath, originalFilename: $uploadedFile->getClientOriginalName(), chunked: true, diff --git a/src/Service/FixtureService.php b/src/Service/FixtureService.php index ab3ec627..3c5d6f85 100644 --- a/src/Service/FixtureService.php +++ b/src/Service/FixtureService.php @@ -13,6 +13,8 @@ use App\Message\ProcessDocumentMessage; use App\Service\Elastic\ElasticService; use App\Service\Ingest\IngestService; +use App\Service\Ingest\Options; +use App\Service\Inventory\InventoryService; use App\Service\Storage\DocumentStorageService; use App\SourceType; use Doctrine\ORM\EntityManagerInterface; @@ -79,11 +81,9 @@ public function create(array $dossier, string $path): void */ protected function createDossier(array $data, string $inventoryPath, ?string $documentsPath): Dossier { - $this->validatePrefix($data['document_prefix']); + $this->ensurePrefixExists($data['document_prefix']); $dossier = new Dossier(); - $dossier->setCreatedAt(new \DateTimeImmutable()); - $dossier->setUpdatedAt(new \DateTimeImmutable()); $dossier->setDateFrom(new \DateTimeImmutable($data['period_from'])); $dossier->setDateTo(new \DateTimeImmutable($data['period_to'])); $dossier->setDecision($data['decision']); @@ -101,10 +101,10 @@ protected function createDossier(array $data, string $inventoryPath, ?string $do $this->doctrine->flush(); $file = new UploadedFile($inventoryPath, 'inventory.pdf', 'application/pdf', null, true); - $errors = $this->inventoryService->processInventory($file, $dossier); + $result = $this->inventoryService->processInventory($file, $dossier); - if (count($errors) > 0) { - throw FixtureInventoryException::forProcessingErrors($errors); + if (! $result->isSuccessful()) { + throw FixtureInventoryException::forProcessingErrors($result->getAllErrors()); } if ($documentsPath) { @@ -115,13 +115,19 @@ protected function createDossier(array $data, string $inventoryPath, ?string $do } $message = new ProcessDocumentMessage( - uuid: $dossier->getId(), + dossierUuid: $dossier->getId(), remotePath: $remotePath, originalFilename: $documentPathFileInfo->getBasename(), chunked: false, ); $this->messageBus->dispatch($message); + } else { + // If no 'real' documents are provided: still index the metadata-only documents + $options = new Options(); + foreach ($dossier->getDocuments() as $document) { + $this->ingester->ingest($document, $options); + } } if (isset($data['fake_documents']) && is_array($data['fake_documents'])) { @@ -166,19 +172,20 @@ protected function createDocument(Dossier $dossier, array $data): void $document->setCreatedAt($data['created_at']); $document->setUpdatedAt($data['updated_at']); $document->setDocumentDate($data['document_date']); - $document->setSourceType($data['source_type']); $document->setDuration($data['duration']); $document->setFamilyId($data['family_id']); $document->setThreadId($data['thread_id']); $document->setPageCount(count($data['pages'])); $document->setSummary($data['summary']); - $document->setUploaded($data['uploaded']); - $document->setFilename($data['filename']); - $document->setMimetype($data['mime_type']); - $document->setFileType($data['file_type']); $document->setSubjects($data['subjects']); $document->setSuspended($data['suspended']); - $document->setWithdrawn($data['withdrawn']); + + $file = $document->getFileInfo(); + $file->setUploaded($data['uploaded']); + $file->setName($data['filename']); + $file->setMimetype($data['mime_type']); + $file->setType($data['file_type']); + $file->setSourceType($data['source_type']); $this->doctrine->persist($document); $dossier->addDocument($document); @@ -190,10 +197,15 @@ protected function createDocument(Dossier $dossier, array $data): void $this->elasticService->setPages($document, $pages); } - private function validatePrefix(string $prefix): void + private function ensurePrefixExists(string $prefix): void { if ($this->doctrine->getRepository(DocumentPrefix::class)->count(['prefix' => $prefix]) === 0) { - throw new \RuntimeException("Prefix $prefix does not exist"); + $documentPrefix = new DocumentPrefix(); + $documentPrefix->setPrefix($prefix); + $documentPrefix->setDescription($prefix); + + $this->doctrine->persist($documentPrefix); + $this->doctrine->flush(); } } diff --git a/src/Service/Ingest/Handler.php b/src/Service/Ingest/Handler.php index 13235136..96375027 100644 --- a/src/Service/Ingest/Handler.php +++ b/src/Service/Ingest/Handler.php @@ -5,6 +5,7 @@ namespace App\Service\Ingest; use App\Entity\Document; +use App\Entity\FileInfo; interface Handler { @@ -14,7 +15,7 @@ interface Handler public function handle(Document $document, Options $options): void; /** - * Returns true when this handler can handle the given mimetype. + * Returns true when this handler can handle the given FileInfo. */ - public function canHandle(string $mimeType): bool; + public function canHandle(FileInfo $fileInfo): bool; } diff --git a/src/Service/Ingest/Handler/AudioHandler.php b/src/Service/Ingest/Handler/AudioHandler.php index ec6e9791..02d22e4d 100644 --- a/src/Service/Ingest/Handler/AudioHandler.php +++ b/src/Service/Ingest/Handler/AudioHandler.php @@ -5,15 +5,24 @@ namespace App\Service\Ingest\Handler; use App\Entity\Document; +use App\Entity\FileInfo; use App\Message\IngestAudioMessage; use App\Service\Ingest\Handler; use App\Service\Ingest\Options; +use Psr\Log\LoggerInterface; +use Symfony\Component\Messenger\MessageBusInterface; -class AudioHandler extends BaseHandler implements Handler +class AudioHandler implements Handler { + public function __construct( + private readonly MessageBusInterface $bus, + private readonly LoggerInterface $logger, + ) { + } + public function handle(Document $document, Options $options): void { - $this->logger->info('Ingesting AUDIO into document', [ + $this->logger->info('Dispatching ingest for audio document', [ 'document' => $document->getId(), ]); @@ -21,8 +30,8 @@ public function handle(Document $document, Options $options): void $this->bus->dispatch($message); } - public function canHandle(string $mimeType): bool + public function canHandle(FileInfo $fileInfo): bool { - return $mimeType === 'audio/mpeg'; + return $fileInfo->getMimetype() === 'audio/mpeg'; } } diff --git a/src/Service/Ingest/Handler/PdfHandler.php b/src/Service/Ingest/Handler/PdfHandler.php index 8e38b0ef..2c62f96f 100644 --- a/src/Service/Ingest/Handler/PdfHandler.php +++ b/src/Service/Ingest/Handler/PdfHandler.php @@ -5,43 +5,24 @@ namespace App\Service\Ingest\Handler; use App\Entity\Document; +use App\Entity\FileInfo; use App\Message\IngestPdfMessage; -use App\Service\DocumentService; use App\Service\Ingest\Handler; use App\Service\Ingest\Options; -use App\Service\Storage\DocumentStorageService; -use App\Service\Worker\Pdf\Extractor\PagecountExtractor; -use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\MessageBusInterface; -/** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class PdfHandler extends BaseHandler implements Handler +class PdfHandler implements Handler { - protected DocumentStorageService $storageService; - protected DocumentService $documentService; - protected PagecountExtractor $extractor; - public function __construct( - MessageBusInterface $bus, - EntityManagerInterface $doctrine, - LoggerInterface $logger, - DocumentStorageService $storageService, - DocumentService $documentService, - PagecountExtractor $extractor, + private readonly MessageBusInterface $bus, + private readonly LoggerInterface $logger, ) { - parent::__construct($bus, $doctrine, $logger); - - $this->storageService = $storageService; - $this->documentService = $documentService; - $this->extractor = $extractor; } public function handle(Document $document, Options $options): void { - $this->logger->info('Ingesting PDF for document', [ + $this->logger->info('Dispatching ingest for PDF document', [ 'document' => $document->getId(), ]); @@ -49,8 +30,8 @@ public function handle(Document $document, Options $options): void $this->bus->dispatch($message); } - public function canHandle(string $mimeType): bool + public function canHandle(FileInfo $fileInfo): bool { - return $mimeType === 'application/pdf'; + return $fileInfo->getMimetype() === 'application/pdf'; } } diff --git a/src/Service/Ingest/IngestLogger.php b/src/Service/Ingest/IngestLogger.php index a0a65f95..f4e927c6 100644 --- a/src/Service/Ingest/IngestLogger.php +++ b/src/Service/Ingest/IngestLogger.php @@ -12,6 +12,7 @@ class IngestLogger implements LoggingTypeInterface { private bool $enabled = true; + private bool $flush = true; public function __construct( private readonly EntityManagerInterface $doctrine, @@ -33,6 +34,11 @@ public function restore(): void $this->enabled = true; } + public function setFlush(bool $flush): void + { + $this->flush = $flush; + } + public function success(Document $document, string $event, string $message): void { $this->writeLogToDatabase($document, $event, $message, true); @@ -58,6 +64,9 @@ private function writeLogToDatabase(Document $document, string $event, string $m $log->setSuccess($succes); $this->doctrine->persist($log); - $this->doctrine->flush(); + + if ($this->flush) { + $this->doctrine->flush(); + } } } diff --git a/src/Service/Ingest/IngestService.php b/src/Service/Ingest/IngestService.php index a59a5845..f6af4112 100644 --- a/src/Service/Ingest/IngestService.php +++ b/src/Service/Ingest/IngestService.php @@ -28,8 +28,8 @@ public function __construct( public function ingest(Document $document, Options $options): void { foreach ($this->handlers as $handler) { - if ($handler->canHandle($document->getMimetype() ?? '')) { - $this->ingestLogger->success($document, 'ingest', 'Starting ingest on ' . $document->getFilename()); + if ($handler->canHandle($document->getFileInfo())) { + $this->ingestLogger->success($document, 'ingest', 'Starting ingest on ' . $document->getFileInfo()->getName()); $handler->handle($document, $options); diff --git a/src/Service/Search/ConfigFactory.php b/src/Service/Search/ConfigFactory.php index 32819636..6124ad82 100644 --- a/src/Service/Search/ConfigFactory.php +++ b/src/Service/Search/ConfigFactory.php @@ -6,7 +6,7 @@ use App\Service\InquiryService; use App\Service\Search\Model\Config; -use App\Service\Search\Model\Facet; +use App\Service\Search\Query\Facet\FacetMappingService; use Symfony\Component\HttpFoundation\Request; class ConfigFactory @@ -15,8 +15,10 @@ class ConfigFactory public const MIN_PAGE_SIZE = 1; public const MAX_PAGE_SIZE = 100; - public function __construct(protected InquiryService $inquiryService) - { + public function __construct( + private readonly InquiryService $inquiryService, + private readonly FacetMappingService $facetMapping, + ) { } /** @@ -32,14 +34,16 @@ public function createFromRequest( $pageNum = max($request->query->getInt('page', 1) - 1, 0); $facets = []; - foreach (Facet::getQueryMapping() as $facetKey => $queryKey) { - if (! $request->query->has($queryKey)) { + foreach ($this->facetMapping->getAll() as $facet) { + if (! $request->query->has($facet->getQueryParam())) { continue; } // Make sure that $items is always an array - $items = $request->query->all()[$queryKey]; - $items = is_array($items) ? array_values($items) : [$items]; + $items = $request->query->all()[$facet->getQueryParam()]; + if (! is_array($items)) { + $items = [$items]; + } // Url decode the strings but not numbers etc foreach ($items as $index => $item) { @@ -48,7 +52,7 @@ public function createFromRequest( } } - $facets[$facetKey] = $items; + $facets[$facet->getFacetKey()] = $items; } // Type is not a facet but must be set directly in the config diff --git a/src/Service/Search/Model/AggregationBucketEntry.php b/src/Service/Search/Model/AggregationBucketEntry.php index ea100634..a21a2e8b 100644 --- a/src/Service/Search/Model/AggregationBucketEntry.php +++ b/src/Service/Search/Model/AggregationBucketEntry.php @@ -6,13 +6,11 @@ class AggregationBucketEntry { - protected string $key; - protected int $count; - - public function __construct(string $key, int $count) - { - $this->key = $key; - $this->count = $count; + public function __construct( + private readonly string $key, + private readonly int $count, + private readonly string $displayValue, + ) { } public function getKey(): string @@ -24,4 +22,9 @@ public function getCount(): int { return $this->count; } + + public function getDisplayValue(): string + { + return $this->displayValue; + } } diff --git a/src/Service/Search/Model/Config.php b/src/Service/Search/Model/Config.php index 06688eb8..d83bcb61 100644 --- a/src/Service/Search/Model/Config.php +++ b/src/Service/Search/Model/Config.php @@ -4,6 +4,8 @@ namespace App\Service\Search\Model; +use App\Service\Search\Query\Facet\FacetDefinition; + class Config { public const OPERATOR_PHRASE = 'phrase'; @@ -34,4 +36,17 @@ public function __construct( public readonly array $dossierInquiries = [], ) { } + + public function hasFacetValues(FacetDefinition $facet): bool + { + return array_key_exists($facet->getFacetKey(), $this->facets) && count($this->facets[$facet->getFacetKey()]) > 0; + } + + /** + * @return array|mixed[] + */ + public function getFacetValues(FacetDefinition $facet): array + { + return $this->facets[$facet->getFacetKey()] ?? []; + } } diff --git a/src/Service/Search/Object/ObjectHandler.php b/src/Service/Search/Object/ObjectHandler.php index 422fedaa..543f53f1 100644 --- a/src/Service/Search/Object/ObjectHandler.php +++ b/src/Service/Search/Object/ObjectHandler.php @@ -98,19 +98,29 @@ public function getPageContent(Document $document, int $pageNr): string { $params = [ 'index' => ElasticConfig::READ_INDEX, - 'id' => $document->getDocumentNr(), 'body' => [ '_source' => false, 'query' => [ - 'nested' => [ - 'path' => 'pages', - 'query' => [ - 'term' => [ - 'pages.page_nr' => $pageNr, + 'bool' => [ + 'must' => [ + [ + 'term' => [ + '_id' => $document->getDocumentNr(), + ], + ], + [ + 'nested' => [ + 'path' => 'pages', + 'query' => [ + 'term' => [ + 'pages.page_nr' => $pageNr, + ], + ], + 'inner_hits' => [ + '_source' => 'pages.content', + ], + ], ], - ], - 'inner_hits' => [ - '_source' => 'pages.content', ], ], ], @@ -122,10 +132,10 @@ public function getPageContent(Document $document, int $pageNr): string $response = $this->elastic->search($params); $response = new TypeArray($response->asArray()); - $ontent = $response->getString('[hits][hits][0][inner_hits][pages][hits][hits][0][_source][content]', ''); + $content = $response->getString('[hits][hits][0][inner_hits][pages][hits][hits][0][_source][content]', ''); - return $ontent; - } catch (\Throwable) { + return $content; + } catch (\Throwable $e) { return ''; } } diff --git a/src/Service/Search/Query/Aggregation/AggregationStrategyInterface.php b/src/Service/Search/Query/Aggregation/AggregationStrategyInterface.php index 93d13ee6..0ad13bfd 100644 --- a/src/Service/Search/Query/Aggregation/AggregationStrategyInterface.php +++ b/src/Service/Search/Query/Aggregation/AggregationStrategyInterface.php @@ -4,10 +4,13 @@ namespace App\Service\Search\Query\Aggregation; +use App\Service\Search\Model\Config; +use App\Service\Search\Query\Facet\FacetDefinition; +use Erichard\ElasticQueryBuilder\Aggregation\AbstractAggregation; + interface AggregationStrategyInterface { - /** - * @return array - */ - public function getQuery(): array; + public function excludeOwnFilters(): bool; + + public function getAggregation(FacetDefinition $facet, Config $config, int $maxCount): AbstractAggregation; } diff --git a/src/Service/Search/Query/Aggregation/TermsAggregationStrategy.php b/src/Service/Search/Query/Aggregation/TermsAggregationStrategy.php index d13be476..9000c61f 100644 --- a/src/Service/Search/Query/Aggregation/TermsAggregationStrategy.php +++ b/src/Service/Search/Query/Aggregation/TermsAggregationStrategy.php @@ -4,31 +4,34 @@ namespace App\Service\Search\Query\Aggregation; +use App\Service\Search\Model\Config; +use App\Service\Search\Query\Dsl\TermsAggregationWithMinDocCount; +use App\Service\Search\Query\Facet\FacetDefinition; +use Erichard\ElasticQueryBuilder\Aggregation\AbstractAggregation; +use Erichard\ElasticQueryBuilder\Constants\SortDirections; + class TermsAggregationStrategy implements AggregationStrategyInterface { public function __construct( - protected string $tagName, - protected string $fieldName, - protected int $maxCount, + // Set to false for AND behaviour in facet counts. + private readonly bool $excludeOwnFilters = true, ) { } - /** - * @return array - */ - public function getQuery(): array + public function getAggregation(FacetDefinition $facet, Config $config, int $maxCount): AbstractAggregation + { + return new TermsAggregationWithMinDocCount( + name: $facet->getFacetKey(), + fieldOrSource: $facet->getPath(), + minDocCount: 1, + orderField: '_count', + orderValue: SortDirections::DESC, + size: $maxCount, + ); + } + + public function excludeOwnFilters(): bool { - return [ - $this->tagName => [ - 'terms' => [ - 'field' => $this->fieldName, - 'size' => $this->maxCount, - 'order' => [ - '_count' => 'desc', - ], - 'min_doc_count' => 0, - ], - ], - ]; + return $this->excludeOwnFilters; } } diff --git a/src/Service/Search/Query/Filter/AndTermFilter.php b/src/Service/Search/Query/Filter/AndTermFilter.php index 98307b94..7b60fc95 100644 --- a/src/Service/Search/Query/Filter/AndTermFilter.php +++ b/src/Service/Search/Query/Filter/AndTermFilter.php @@ -4,26 +4,31 @@ namespace App\Service\Search\Query\Filter; +use App\Service\Search\Model\Config; +use App\Service\Search\Query\Facet\FacetDefinition; +use Erichard\ElasticQueryBuilder\Query\BoolQuery; +use Erichard\ElasticQueryBuilder\Query\TermQuery; + /** * Meaning that all values must match. */ class AndTermFilter implements FilterInterface { - public function __construct(protected string $field) + public function addToQuery(FacetDefinition $facet, BoolQuery $query, Config $config, string $prefix = ''): void { - } + $values = $config->getFacetValues($facet); + if (count($values) === 0) { + return; + } - public function getQuery(array $values): ?array - { - $filters = []; foreach ($values as $value) { - $filters[] = ['term' => [$this->field => $value]]; + /** @var string $value */ + $query->addFilter( + new TermQuery( + field: $prefix . $facet->getPath(), + value: $value + ) + ); } - - return [ - 'bool' => [ - 'filter' => $filters, - ], - ]; } } diff --git a/src/Service/Search/Query/Filter/DateRangeFilter.php b/src/Service/Search/Query/Filter/DateRangeFilter.php index 7a86999a..5d523383 100644 --- a/src/Service/Search/Query/Filter/DateRangeFilter.php +++ b/src/Service/Search/Query/Filter/DateRangeFilter.php @@ -4,30 +4,43 @@ namespace App\Service\Search\Query\Filter; +use App\Service\Search\Model\Config; +use App\Service\Search\Query\Facet\FacetDefinition; +use Erichard\ElasticQueryBuilder\Query\BoolQuery; +use Erichard\ElasticQueryBuilder\Query\RangeQuery; + class DateRangeFilter implements FilterInterface { - public function __construct(protected string $field, protected string $comparisonOperator) - { + public function __construct( + private readonly string $comparisonOperator + ) { } - public function getQuery(array $values): ?array + public function addToQuery(FacetDefinition $facet, BoolQuery $query, Config $config, string $prefix = ''): void { + $values = $config->getFacetValues($facet); if (count($values) !== 1) { - return null; + return; } $date = $this->asDate(array_shift($values)); if ($date === null) { - return null; + return; + } + + $rangeQuery = new RangeQuery($prefix . $facet->getPath()); + switch ($this->comparisonOperator) { + case 'lte': + $rangeQuery->lte($date->format('Y-m-d')); + break; + case 'gte': + $rangeQuery->gte($date->format('Y-m-d')); + break; + default: + throw new \RuntimeException('Unknown DateRangeFilter comparison operator: ' . $this->comparisonOperator); } - return [ - 'range' => [ - $this->field => [ - $this->comparisonOperator => $date->format('Y-m-d'), - ], - ], - ]; + $query->addFilter($rangeQuery); } protected function asDate(mixed $value): ?\DateTimeImmutable @@ -38,7 +51,7 @@ protected function asDate(mixed $value): ?\DateTimeImmutable try { return new \DateTimeImmutable($value); - } catch (\Exception $e) { + } catch (\Exception) { return null; } } diff --git a/src/Service/Search/Query/Filter/FilterInterface.php b/src/Service/Search/Query/Filter/FilterInterface.php index 77c1de9e..3ca060ce 100644 --- a/src/Service/Search/Query/Filter/FilterInterface.php +++ b/src/Service/Search/Query/Filter/FilterInterface.php @@ -4,12 +4,11 @@ namespace App\Service\Search\Query\Filter; +use App\Service\Search\Model\Config; +use App\Service\Search\Query\Facet\FacetDefinition; +use Erichard\ElasticQueryBuilder\Query\BoolQuery; + interface FilterInterface { - /** - * @param mixed[] $values - * - * @return ?array - */ - public function getQuery(array $values): ?array; + public function addToQuery(FacetDefinition $facet, BoolQuery $query, Config $config, string $prefix = ''): void; } diff --git a/src/Service/Search/Query/Filter/OrTermFilter.php b/src/Service/Search/Query/Filter/OrTermFilter.php index 2929beb9..ec7d6276 100644 --- a/src/Service/Search/Query/Filter/OrTermFilter.php +++ b/src/Service/Search/Query/Filter/OrTermFilter.php @@ -4,26 +4,29 @@ namespace App\Service\Search\Query\Filter; +use App\Service\Search\Model\Config; +use App\Service\Search\Query\Facet\FacetDefinition; +use Erichard\ElasticQueryBuilder\Query\BoolQuery; +use Erichard\ElasticQueryBuilder\Query\TermsQuery; + /** * Meaning that at least one value must match. */ class OrTermFilter implements FilterInterface { - public function __construct(protected string $field) + public function addToQuery(FacetDefinition $facet, BoolQuery $query, Config $config, string $prefix = ''): void { - } + /** @var string[] $values */ + $values = $config->getFacetValues($facet); + if (count($values) === 0) { + return; + } - /** - * @param mixed[] $values - * - * @return array - */ - public function getQuery(array $values): array - { - return [ - 'terms' => [ - $this->field => $values, - ], - ]; + $query->addFilter( + new TermsQuery( + field: $prefix . $facet->getPath(), + values: $values + ) + ); } } diff --git a/src/Service/Search/Query/QueryGenerator.php b/src/Service/Search/Query/QueryGenerator.php index 9b245dbc..cfecd243 100644 --- a/src/Service/Search/Query/QueryGenerator.php +++ b/src/Service/Search/Query/QueryGenerator.php @@ -6,65 +6,59 @@ use App\ElasticConfig; use App\Service\Search\Model\Config; -use App\Service\Search\Model\Facet; -use App\Service\Search\Query\Aggregation\NestedAggregationStrategy; -use App\Service\Search\Query\Aggregation\TermsAggregationStrategy; -use App\Service\Search\Query\DossierStrategy\TopLevelDossierStrategy; +use App\Service\Search\Query\Condition\ContentAccessConditions; +use App\Service\Search\Query\Condition\FacetConditions; +use App\Service\Search\Query\Condition\SearchTermConditions; +use Erichard\ElasticQueryBuilder\Query\BoolQuery; +use Erichard\ElasticQueryBuilder\Query\QueryStringQuery; +use Erichard\ElasticQueryBuilder\QueryBuilder; class QueryGenerator { public function __construct( - protected DocumentQueryGenerator $docQueryGen, - protected DossierQueryGenerator $dosQueryGen, - protected Config $config + private readonly AggregationGenerator $aggregationGenerator, + private readonly ContentAccessConditions $accessConditions, + private readonly FacetConditions $facetConditions, + private readonly SearchTermConditions $searchTermConditions, ) { } - /** - * @return array - */ - public function createFacetsQuery(): array + public function createFacetsQuery(Config $config): QueryBuilder { - $query = [ - 'index' => ElasticConfig::READ_INDEX, - 'body' => [ - 'size' => 0, - '_source' => false, - 'aggs' => $this->addAggregations(5), - 'query' => $this->addQuery(), - ], - ]; + $queryBuilder = new QueryBuilder(); + $queryBuilder->setIndex(ElasticConfig::READ_INDEX); + $queryBuilder->setSize(0); + $queryBuilder->setSource(false); - return $query; + $this->addQuery($queryBuilder, $config); + + $this->aggregationGenerator->addAggregations($queryBuilder, $config, 5); + + return $queryBuilder; } - /** - * @return array - */ - public function createExtendedFacetsQuery(): array + public function createExtendedFacetsQuery(Config $config): QueryBuilder { - $query = [ - 'index' => ElasticConfig::READ_INDEX, - 'body' => [ - 'size' => 0, - '_source' => false, - 'aggs' => $this->addAggregations(25), - ], - ]; + $queryBuilder = new QueryBuilder(); + $queryBuilder->setIndex(ElasticConfig::READ_INDEX); + $queryBuilder->setSize(0); + $queryBuilder->setSource(false); - return $query; + $this->addQuery($queryBuilder, $config); + $this->aggregationGenerator->addAggregations($queryBuilder, $config, 25); + + return $queryBuilder; } - /** - * @return array - */ - public function createQuery(): array + public function createQuery(Config $config): QueryBuilder { - $query = [ - 'index' => ElasticConfig::READ_INDEX, + $queryBuilder = new QueryBuilder(); + $queryBuilder->setIndex(ElasticConfig::READ_INDEX); + $queryBuilder->setSize($config->limit); + $queryBuilder->setFrom($config->offset); + + $params = [ 'body' => [ - 'size' => $this->config->limit, - 'from' => $this->config->offset, '_source' => [ 'excludes' => [ 'content', @@ -73,154 +67,69 @@ public function createQuery(): array 'dossiers.inquiry_ids', ], ], - 'query' => $this->addQuery(), - 'highlight' => $this->addHighlighting(), - 'aggs' => $this->addAggregations(25), - 'suggest' => $this->addSuggestions(), - ], - ]; - - return $query; - } - - /** - * @return array - */ - protected function addSuggestions(): array - { - $suggestions = [ - ElasticConfig::SUGGESTIONS_SEARCH_INPUT => [ - 'text' => $this->config->query, - 'term' => [ - 'field' => 'content_for_suggestions', - 'size' => 3, - 'sort' => 'frequency', - 'suggest_mode' => 'popular', - 'string_distance' => 'jaro_winkler', - ], ], ]; - return $suggestions; - } - - /** - * @return array - */ - protected function addQuery(): array - { - $documentQuery = $this->getDocumentQuery(); - $dossierQuery = $this->getDossierQuery(); - $combinedQuery = $this->combineQueries([$documentQuery, $dossierQuery]); - - return $combinedQuery; - } - - /** - * @return array - */ - protected function getDocumentQuery(): array - { - if (! in_array($this->config->searchType, [Config::TYPE_DOCUMENT, Config::TYPE_ALL])) { - return []; + if ($config->query !== '') { + $params['body']['suggest'] = $this->getSuggestParams($config); } - $dossierConditions = $this->docQueryGen->getConditions($this->config); + $queryBuilder->setParams($params); + + $this->addQuery($queryBuilder, $config); + $this->aggregationGenerator->addAggregations($queryBuilder, $config, 25); + $this->aggregationGenerator->addDocTypeAggregations($queryBuilder); + $this->addHighlight($queryBuilder, $config); - return $dossierConditions; + return $queryBuilder; } - /** - * @return array - */ - protected function getDossierQuery(): array + private function addQuery(QueryBuilder $queryBuilder, Config $config): void { - if (! in_array($this->config->searchType, [Config::TYPE_DOSSIER, Config::TYPE_ALL])) { - return []; - } + $query = new BoolQuery(); - $dossierConditions = $this->dosQueryGen->getConditions($this->config, new TopLevelDossierStrategy()); + $this->accessConditions->applyToQuery($config, $query); + $this->facetConditions->applyToQuery($config, $query); + $this->searchTermConditions->applyToQuery($config, $query); - return $dossierConditions; + $queryBuilder->setQuery($query); } /** - * @param array> $queries - * * @return array */ - protected function combineQueries(array $queries): array + private function getSuggestParams(Config $config): array { - $queries = array_values(array_filter($queries)); - $count = count($queries); - - return match ($count) { - 0 => [], - 1 => $queries[0], - default => [ - 'bool' => [ - 'should' => $queries, - 'minimum_should_match' => 1, + return [ + ElasticConfig::SUGGESTIONS_SEARCH_INPUT => [ + 'text' => $config->query, + 'term' => [ + 'field' => 'content_for_suggestions', + 'size' => 3, + 'sort' => 'frequency', + 'suggest_mode' => 'popular', + 'string_distance' => 'jaro_winkler', ], ], - }; + ]; } - protected function addAggregations(int $maxCount = 5): \stdClass + private function addHighlight(QueryBuilder $queryBuilder, Config $config): void { - if (! $this->config->aggregations) { - return (object) []; + if ($config->query === '') { + return; } - // Based on what type we are searching for, we need to aggregate on different fields - // When searching on dossiers only, we never find aggregations for 'dossiers.departments.name' for instance, but only for 'department.name' - $aggregationConfig = [ - Config::TYPE_DOCUMENT => [ - new NestedAggregationStrategy('dossiers', 'dossiers', [ - new TermsAggregationStrategy(Facet::FACET_DEPARTMENT, 'dossiers.departments.name', $maxCount), - new TermsAggregationStrategy(Facet::FACET_OFFICIAL, 'dossiers.government_officials.name', $maxCount), - new TermsAggregationStrategy(Facet::FACET_PERIOD, 'dossiers.date_period', $maxCount), - ]), - new TermsAggregationStrategy(Facet::FACET_SUBJECT, 'subjects', $maxCount), - new TermsAggregationStrategy(Facet::FACET_SOURCE, 'source_type', $maxCount), - new TermsAggregationStrategy(Facet::FACET_GROUNDS, 'grounds', $maxCount), - new TermsAggregationStrategy(Facet::FACET_JUDGEMENT, 'judgement', $maxCount), - ], - Config::TYPE_DOSSIER => [ - new TermsAggregationStrategy(Facet::FACET_DEPARTMENT, 'departments.name', $maxCount), - new TermsAggregationStrategy(Facet::FACET_OFFICIAL, 'government_officials.name', $maxCount), - new TermsAggregationStrategy(Facet::FACET_PERIOD, 'date_period', $maxCount), - new TermsAggregationStrategy(Facet::FACET_SUBJECT, 'subjects', $maxCount), - new TermsAggregationStrategy(Facet::FACET_SOURCE, 'source_type', $maxCount), - new TermsAggregationStrategy(Facet::FACET_GROUNDS, 'grounds', $maxCount), - new TermsAggregationStrategy(Facet::FACET_JUDGEMENT, 'judgement', $maxCount), - ], - ]; - $aggregationConfig[Config::TYPE_ALL] = $aggregationConfig[Config::TYPE_DOCUMENT]; - - $aggregations = []; - foreach ($aggregationConfig[$this->config->searchType] as $strategy) { - $queryPart = $strategy->getQuery(); - $aggregations = array_merge_recursive($aggregations, $queryPart); - } - - $aggregations['unique_dossiers'] = [ - 'cardinality' => [ - 'field' => 'dossier_nr', - ], - ]; - - return (object) $aggregations; - } + // Hightlighting uses a 'clean' query with additional filters like status. + // This is very important, otherwise filter values like 'document' and statuses will be highlighted in content. + $query = new QueryStringQuery( + query: $config->query, + fields: ['title', 'summary', 'decision_content', 'dossiers.summary', 'dossiers.title', 'pages.content'], + ); - /** - * @return array - */ - protected function addHighlighting(): array - { - return [ + $queryBuilder->setHighlight([ 'max_analyzed_offset' => 1000000, - 'pre_tags' => [''], + 'pre_tags' => [''], 'post_tags' => [''], 'fields' => [ // Document object @@ -250,8 +159,14 @@ protected function addHighlighting(): array 'number_of_fragments' => 5, 'type' => 'unified', ], + 'decision_content' => [ + 'fragment_size' => 50, + 'number_of_fragments' => 5, + 'type' => 'unified', + ], ], - 'require_field_match' => false, - ]; + 'require_field_match' => true, + 'highlight_query' => $query->build(), + ]); } } diff --git a/src/Service/Search/Result/Result.php b/src/Service/Search/Result/Result.php index 96e68d98..25d7bece 100644 --- a/src/Service/Search/Result/Result.php +++ b/src/Service/Search/Result/Result.php @@ -6,6 +6,7 @@ use App\Service\Search\Model\Aggregation; use App\Service\Search\Model\Suggestion; +use App\ValueObject\FilterDetails; use Knp\Component\Pager\Pagination\AbstractPagination; use Knp\Component\Pager\Pagination\PaginationInterface; @@ -38,11 +39,27 @@ class Result /** @var array|mixed[] */ protected array $query; // Actual query used to search + protected FilterDetails $filterDetails; // Details about additional filters (non-facet) + + private int $resultCount; // Total number of result items + public static function create(): self { return new self(); } + public function getResultCount(): int + { + return $this->resultCount; + } + + public function setResultCount(int $resultCount): Result + { + $this->resultCount = $resultCount; + + return $this; + } + public function getDocumentCount(): int { return $this->documentCount; @@ -258,4 +275,16 @@ public function setType(string $type): Result return $this; } + + public function getFilterDetails(): FilterDetails + { + return $this->filterDetails; + } + + public function setFilterDetails(FilterDetails $filterDetails): self + { + $this->filterDetails = $filterDetails; + + return $this; + } } diff --git a/src/Service/Search/Result/ResultTransformer.php b/src/Service/Search/Result/ResultTransformer.php index 4bcb285d..3ca9be95 100644 --- a/src/Service/Search/Result/ResultTransformer.php +++ b/src/Service/Search/Result/ResultTransformer.php @@ -6,11 +6,13 @@ use App\Entity\Document; use App\Entity\Dossier; +use App\Entity\Inquiry; use App\Service\Search\Model\Aggregation; -use App\Service\Search\Model\AggregationBucketEntry; use App\Service\Search\Model\Config; use App\Service\Search\Model\Suggestion; use App\Service\Search\Model\SuggestionEntry; +use App\ValueObject\FilterDetails; +use App\ValueObject\InquiryDescription; use Doctrine\ORM\EntityManagerInterface; use Elastic\Elasticsearch\Response\Elasticsearch; use Jaytaph\TypeArray\TypeArray; @@ -25,7 +27,8 @@ class ResultTransformer public function __construct( protected EntityManagerInterface $doctrine, protected LoggerInterface $logger, - protected PaginatorInterface $paginator + protected PaginatorInterface $paginator, + private readonly AggregationMapper $aggregationMapper, ) { } @@ -63,6 +66,8 @@ public function transform(array $query, Config $config, ?Elasticsearch $response $result->setPagination($pagination); } + $result->setFilterDetails($this->getFilterDetails($config)); + return $result; } @@ -82,10 +87,9 @@ protected function transformResults(Config $config, Elasticsearch $response): Re return $result; } - $result->setDocumentCount($typedResponse->getInt('[hits][total][value]', 0)); - if ($config->aggregations) { - $result->setDossierCount($typedResponse->getInt('[aggregations][unique_dossiers][value]', 0)); - } + $result->setResultCount($typedResponse->getInt('[hits][total][value]', 0)); + $result->setDossierCount($typedResponse->getInt('[aggregations][unique_dossiers][value]', 0)); + $result->setDocumentCount($typedResponse->getInt('[aggregations][unique_documents][value]', 0)); $suggestions = $this->transformSuggestions($typedResponse); if ($suggestions) { @@ -165,13 +169,10 @@ protected function transformAggregations(TypeArray $response): array continue; } - // Regular aggregation, iterate over the buckets and create entries - $entries = []; - foreach ($aggregation->getIterable('[buckets]') as $bucket) { - $entries[] = new AggregationBucketEntry($bucket->getString('[key]'), $bucket->getInt('[doc_count]')); - } - - $ret[] = new Aggregation(strval($name), $entries); + $ret[] = $this->aggregationMapper->map( + strval($name), + $aggregation->getIterable('[buckets]') + ); } return $ret; @@ -201,7 +202,7 @@ protected function extractDocumentEntry(TypeArray $hit): ?ResultEntry $highlightPaths = [ '[highlight][pages.content]', '[highlight][dossiers.title]', - '[highlight][dossiers.title]', + '[highlight][dossiers.summary]', ]; $highlightData = $this->getHighlightData($hit, $highlightPaths); @@ -230,6 +231,7 @@ protected function extractDossierEntry(TypeArray $hit): ?ResultEntry $highlightPaths = [ '[highlight][title]', '[highlight][summary]', + '[highlight][decision_content]', ]; $highlightData = $this->getHighlightData($hit, $highlightPaths); @@ -260,4 +262,33 @@ protected function getHighlightData(TypeArray $hit, array $paths): array /** @var string[] $highlightData */ return $highlightData; } + + /** + * @param string[] $inquiryIds + * + * @return InquiryDescription[] + */ + private function getInquiryDescriptions(array $inquiryIds): array + { + if (count($inquiryIds) === 0) { + return []; + } + + return array_map( + static fn (Inquiry $inquiry): InquiryDescription => InquiryDescription::fromEntity($inquiry), + $this->doctrine->getRepository(Inquiry::class)->findBy(['id' => $inquiryIds]) + ); + } + + private function getFilterDetails(Config $config): FilterDetails + { + /** @var string[] $dossierNrs */ + $dossierNrs = $config->facets['dnr'] ?? []; + + return new FilterDetails( + $this->getInquiryDescriptions($config->dossierInquiries), + $this->getInquiryDescriptions($config->documentInquiries), + $dossierNrs, + ); + } } diff --git a/src/Service/Search/SearchService.php b/src/Service/Search/SearchService.php index e9dd5cc9..8c0c2868 100644 --- a/src/Service/Search/SearchService.php +++ b/src/Service/Search/SearchService.php @@ -8,7 +8,7 @@ use App\Service\Elastic\ElasticClientInterface; use App\Service\Search\Model\Config; use App\Service\Search\Object\ObjectHandler; -use App\Service\Search\Query\QueryGeneratorFactory; +use App\Service\Search\Query\QueryGenerator; use App\Service\Search\Result\Result; use App\Service\Search\Result\ResultTransformer; use Elastic\Elasticsearch\Response\Elasticsearch; @@ -19,7 +19,7 @@ class SearchService public function __construct( protected ElasticClientInterface $elastic, protected LoggerInterface $logger, - protected QueryGeneratorFactory $queryGenFactory, + protected QueryGenerator $queryGenerator, protected ObjectHandler $objectHandler, protected ResultTransformer $resultTransformer ) { @@ -27,18 +27,16 @@ public function __construct( public function searchFacets(Config $config): Result { - $queryGenerator = $this->queryGenFactory->create($config); - $query = $queryGenerator->createFacetsQuery(); + $query = $this->queryGenerator->createFacetsQuery($config); - return $this->doSearch($query, $config); + return $this->doSearch($query->build(), $config); } public function search(Config $config): Result { - $queryGenerator = $this->queryGenFactory->create($config); - $query = $queryGenerator->createQuery(); + $query = $this->queryGenerator->createQuery($config); - return $this->doSearch($query, $config); + return $this->doSearch($query->build(), $config); } public function isIngested(Document $document): bool @@ -53,12 +51,10 @@ public function getPageContent(Document $document, int $pageNr): string public function retrieveExtendedFacets(): Result { - $queryGenerator = $this->queryGenFactory->create(); - $query = $queryGenerator->createExtendedFacetsQuery(); - $config = new Config(limit: 0); + $query = $this->queryGenerator->createExtendedFacetsQuery($config); - return $this->doSearch($query, $config); + return $this->doSearch($query->build(), $config); } /** diff --git a/src/Service/SqlDump/NodeVisitor.php b/src/Service/SqlDump/NodeVisitor.php index 94de3706..5507a559 100644 --- a/src/Service/SqlDump/NodeVisitor.php +++ b/src/Service/SqlDump/NodeVisitor.php @@ -10,6 +10,10 @@ use PhpParser\NodeVisitorAbstract; use Symfony\Component\Console\Output\OutputInterface; +/** + * This class is a node visitor for the sql dump tool. It will traverse the AST and find the up method, and then + * process the statements in that method. If there is an addSql statement, it will write the SQL to the output. + */ class NodeVisitor extends NodeVisitorAbstract { protected OutputInterface $output; diff --git a/src/Service/Storage/DocumentStorageService.php b/src/Service/Storage/DocumentStorageService.php index 7c21f0b6..34aaae22 100644 --- a/src/Service/Storage/DocumentStorageService.php +++ b/src/Service/Storage/DocumentStorageService.php @@ -5,6 +5,7 @@ namespace App\Service\Storage; use App\Entity\Document; +use App\Entity\EntityWithFileInfo; use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\FileAttributes; use League\Flysystem\FilesystemOperator; @@ -154,7 +155,11 @@ public function store(\SplFileInfo $localFile, string $remotePath): bool // An exception occurred when trying to store the file. return false; } finally { - fclose($stream); + // Close the stream if not already closed + // @phpstan-ignore-next-line + if (is_resource($stream)) { + @fclose($stream); + } } return true; @@ -215,7 +220,7 @@ public function existsPage(Document $document, int $pageNr): bool public function retrieveDocument(Document $document, string $localPath): bool { - $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFilepath() ?? '')); + $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFileInfo()->getPath() ?? '')); return $this->retrieve($remotePath, $localPath); } @@ -223,14 +228,14 @@ public function retrieveDocument(Document $document, string $localPath): bool /** * @return resource|null */ - public function retrieveResourceDocument(Document $document) + public function retrieveResourceDocument(EntityWithFileInfo $document) { - $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFilepath() ?? '')); + $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFileInfo()->getPath() ?? '')); return $this->retrieveResource($remotePath); } - public function storeDocument(\SplFileInfo $localFile, Document $document): bool + public function storeDocument(\SplFileInfo $localFile, EntityWithFileInfo $document): bool { $remotePath = $this->generateDocumentPath($document, $localFile); @@ -240,12 +245,13 @@ public function storeDocument(\SplFileInfo $localFile, Document $document): bool } // Store file information in document record - $document->setFilepath($remotePath); - $document->setFilesize($localFile->getSize()); + $file = $document->getFileInfo(); + $file->setPath($remotePath); + $file->setSize($localFile->getSize()); $foundationFile = new File($localFile->getPathname()); - $document->setMimetype($foundationFile->getMimeType() ?? ''); - $document->setUploaded(true); + $file->setMimetype($foundationFile->getMimeType() ?? ''); + $file->setUploaded(true); $this->doctrine->persist($document); $this->doctrine->flush(); @@ -255,7 +261,7 @@ public function storeDocument(\SplFileInfo $localFile, Document $document): bool public function existsDocument(Document $document): bool { - $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFilepath() ?? '')); + $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFileInfo()->getPath() ?? '')); return $this->exists($remotePath); } @@ -286,6 +292,11 @@ public function download(string $remotePath): string|false return $localPath; } + // Remove the temporary file when retrieval fails + if (file_exists($localPath)) { + unlink($localPath); + } + return false; } @@ -296,9 +307,9 @@ public function downloadPage(Document $document, int $pageNr): string|false return $this->download($remotePath); } - public function downloadDocument(Document $document): string|false + public function downloadDocument(EntityWithFileInfo $document): string|false { - $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFilepath() ?? '')); + $remotePath = $this->generateDocumentPath($document, new \SplFileInfo($document->getFileInfo()->getPath() ?? '')); return $this->download($remotePath); } @@ -309,10 +320,10 @@ public function downloadDocument(Document $document): string|false * Since download*() does not copy the file but actually points to the given file when * the filesystem is local, this function will NOT delete the file in that case. */ - public function removeDownload(string $localPath): void + public function removeDownload(string $localPath, bool $forceLocalDelete = false): void { // Don't remove when the storage is local. It would point to the actual stored file - if ($this->isLocal) { + if ($this->isLocal && ! $forceLocalDelete) { return; } @@ -326,7 +337,7 @@ public function removeDownload(string $localPath): void * Returns the root path of a document. Normally, this is /{prefix}/{suffix}, where prefix are the first two characters of the * SHA256 hash, and suffix is the rest of the SHA256 hash. */ - protected function getRootPathForDocument(Document $document): string + protected function getRootPathForDocument(EntityWithFileInfo $document): string { $documentId = (string) $document->getId(); $hash = hash('sha256', $documentId); @@ -340,7 +351,7 @@ protected function getRootPathForDocument(Document $document): string /** * Generates the path to a document. It will use the original filename of the file object if it's an uploaded file. */ - protected function generateDocumentPath(Document $document, \SplFileInfo $file): string + protected function generateDocumentPath(EntityWithFileInfo $document, \SplFileInfo $file): string { $rootPath = $this->getRootPathForDocument($document); @@ -390,4 +401,26 @@ public function list(string $remotePath, string $filter = '*'): array return $ret; } + + public function deleteAllFilesForDocument(Document $document): bool + { + try { + $path = $this->generateDocumentPath($document, new \SplFileInfo($document->getFileInfo()->getPath() ?? '')); + $this->storage->delete($path); + + for ($pageNr = 1; $pageNr <= $document->getPageCount(); $pageNr++) { + $path = $this->generatePagePath($document, $pageNr); + $this->storage->delete($path); + } + } catch (\Throwable $e) { + $this->logger->error('Could not delete files from storage', [ + 'exception' => $e->getMessage(), + 'path' => $path ?? '', + ]); + + return false; + } + + return true; + } } diff --git a/src/Service/Storage/ThumbnailStorageService.php b/src/Service/Storage/ThumbnailStorageService.php index 241168d3..328d863e 100644 --- a/src/Service/Storage/ThumbnailStorageService.php +++ b/src/Service/Storage/ThumbnailStorageService.php @@ -161,6 +161,56 @@ public function exists(Document $document, int $pageNr = null): bool return false; } + /** + * Returns the filesize in bytes, or 0 when file is not found (or empty, not readable etc). + */ + public function fileSize(Document $document, int $pageNr = null): int + { + if ($pageNr) { + $path = $this->generatePagePath($document, $pageNr); + } else { + $path = $this->generateDocumentPath($document); + } + + $this->logger->info('Path: ' . $path); + + // Create path if not exists + try { + return $this->storage->fileSize($path); + } catch (FilesystemException $e) { + // Could not create directory + $this->logger->error('Could not check file size', [ + 'document' => $document->getId(), + 'path' => $path, + 'exception' => $e->getMessage(), + ]); + } + + return 0; + } + + public function deleteAllThumbsForDocument(Document $document): bool + { + try { + $path = $this->generateDocumentPath($document); + $this->storage->delete($path); + + for ($pageNr = 1; $pageNr <= $document->getPageCount(); $pageNr++) { + $path = $this->generatePagePath($document, $pageNr); + $this->storage->delete($path); + } + } catch (\Throwable $e) { + $this->logger->error('Could not delete thumbnails from storage', [ + 'exception' => $e->getMessage(), + 'path' => $path ?? '', + ]); + + return false; + } + + return true; + } + /** * Returns the root path of a document. Normally, this is /{prefix}/{suffix}, where prefix are the first two characters of the * SHA256 hash, and suffix is the rest of the SHA256 hash. diff --git a/src/Service/Worker/Audio/Extractor/AudioExtractor.php b/src/Service/Worker/Audio/Extractor/AudioExtractor.php index 5f0aa2c2..38348ea1 100644 --- a/src/Service/Worker/Audio/Extractor/AudioExtractor.php +++ b/src/Service/Worker/Audio/Extractor/AudioExtractor.php @@ -72,6 +72,8 @@ protected function extractContentFromAudio(Document $document): array $content = $this->extractText($localAudioPath); + $this->documentStorage->removeDownload($localAudioPath); + return [$content, $metaData]; } diff --git a/src/Service/Worker/Audio/Extractor/WaveImageExtractor.php b/src/Service/Worker/Audio/Extractor/WaveImageExtractor.php index bfeb0811..d6c632c0 100644 --- a/src/Service/Worker/Audio/Extractor/WaveImageExtractor.php +++ b/src/Service/Worker/Audio/Extractor/WaveImageExtractor.php @@ -75,6 +75,8 @@ public function extract(Document $document, bool $forceRefresh): void $process = new Process($params); $process->run(); + $this->documentStorage->removeDownload($localPath); + if (! $process->isSuccessful()) { $this->logger->error('Failed to create wave image for audio', [ 'document' => $document->getId(), @@ -84,7 +86,6 @@ public function extract(Document $document, bool $forceRefresh): void ]); $this->fileUtils->deleteTempDirectory($tempDir); - $this->documentStorage->removeDownload($localPath); return; } @@ -92,6 +93,5 @@ public function extract(Document $document, bool $forceRefresh): void $this->thumbnailStorage->store($document, new File($targetPath), 0); $this->fileUtils->deleteTempDirectory($tempDir); - $this->documentStorage->removeDownload($localPath); } } diff --git a/src/Service/Worker/Pdf/Extractor/DocumentContentExtractor.php b/src/Service/Worker/Pdf/Extractor/DocumentContentExtractor.php index 161b7201..d47c7232 100644 --- a/src/Service/Worker/Pdf/Extractor/DocumentContentExtractor.php +++ b/src/Service/Worker/Pdf/Extractor/DocumentContentExtractor.php @@ -69,6 +69,8 @@ protected function extractContentFromPdf(Document $document): array $tikaData = $this->tika->extract($localPdfPath); $documentContent = $tikaData['X-TIKA:content'] ?? ''; + $this->documentStorage->removeDownload($localPdfPath); + return [$documentContent, $tikaData]; } diff --git a/src/Service/Worker/Pdf/Extractor/PageContentExtractor.php b/src/Service/Worker/Pdf/Extractor/PageContentExtractor.php index 7e13128a..437a751d 100644 --- a/src/Service/Worker/Pdf/Extractor/PageContentExtractor.php +++ b/src/Service/Worker/Pdf/Extractor/PageContentExtractor.php @@ -75,6 +75,8 @@ protected function extractContentFromPdf(Document $document, int $pageNr): array $pageContent[] = $tikaData['X-TIKA:content'] ?? ''; $pageContent[] = $this->tesseract->extract($localPdfPath); + $this->documentStorage->removeDownload($localPdfPath); + return [join("\n", $pageContent), $tikaData]; } diff --git a/src/Service/Worker/Pdf/Extractor/PageExtractor.php b/src/Service/Worker/Pdf/Extractor/PageExtractor.php index f54089ed..8dc88d9e 100644 --- a/src/Service/Worker/Pdf/Extractor/PageExtractor.php +++ b/src/Service/Worker/Pdf/Extractor/PageExtractor.php @@ -38,6 +38,13 @@ public function extract(Document $document, int $pageNr, bool $forceRefresh): vo } $localPath = $this->documentStorage->downloadDocument($document); + if (! $localPath) { + $this->logger->error('cannot download document from storage', [ + 'document' => $document->getId(), + ]); + + return; + } $tempDir = $this->fileUtils->createTempDir(); $targetPath = $tempDir . '/page.pdf'; @@ -47,6 +54,9 @@ public function extract(Document $document, int $pageNr, bool $forceRefresh): vo $process = new Process($params); $process->run(); + // Remove local file + $this->documentStorage->removeDownload($localPath); + if (! $process->isSuccessful()) { $this->logger->error('Failed to fetch PDF page: ', [ 'document' => $document->getId(), diff --git a/src/Service/Worker/Pdf/Extractor/PagecountExtractor.php b/src/Service/Worker/Pdf/Extractor/PagecountExtractor.php index 3ca3d276..40ce33d9 100644 --- a/src/Service/Worker/Pdf/Extractor/PagecountExtractor.php +++ b/src/Service/Worker/Pdf/Extractor/PagecountExtractor.php @@ -60,11 +60,20 @@ protected function setCachedPageCount(Document $document, int $count): void protected function extractPageCountFromPdf(Document $document): int { $localPdfPath = $this->documentStorage->downloadDocument($document); + if (! $localPdfPath) { + $this->logger->error('Failed to download document for page count extraction', [ + 'document' => $document->getDocumentNr(), + ]); + + return 0; + } $params = ['/usr/bin/pdftk', $localPdfPath, 'dump_data']; $process = new Process($params); $process->run(); + $this->documentStorage->removeDownload($localPdfPath); + if (! $process->isSuccessful()) { $this->logger->error('Failed to get page count: ', [ 'sourcePath' => $localPdfPath, diff --git a/src/Service/Worker/Pdf/Extractor/ThumbnailExtractor.php b/src/Service/Worker/Pdf/Extractor/ThumbnailExtractor.php index 411b783c..8f4c2358 100644 --- a/src/Service/Worker/Pdf/Extractor/ThumbnailExtractor.php +++ b/src/Service/Worker/Pdf/Extractor/ThumbnailExtractor.php @@ -58,6 +58,8 @@ public function extract(Document $document, int $pageNr, bool $forceRefresh): vo $process = new Process($params); $process->run(); + $this->documentStorage->removeDownload($localPath); + if (! $process->isSuccessful()) { $this->logger->error('Failed to create thumbnail for document', [ 'document' => $document->getId(), @@ -68,7 +70,6 @@ public function extract(Document $document, int $pageNr, bool $forceRefresh): vo ]); $this->fileUtils->deleteTempDirectory($tempDir); - $this->documentStorage->removeDownload($localPath); return; } @@ -77,6 +78,5 @@ public function extract(Document $document, int $pageNr, bool $forceRefresh): vo $this->thumbnailStorage->store($document, $file, $pageNr); $this->fileUtils->deleteTempDirectory($tempDir); - $this->documentStorage->removeDownload($localPath); } } diff --git a/src/SourceType.php b/src/SourceType.php index 34b02598..2e88c182 100644 --- a/src/SourceType.php +++ b/src/SourceType.php @@ -70,6 +70,7 @@ class SourceType ], ]; + // Finds the given source type in the list of known types public static function getType(string $target): string { $target = strtolower($target); @@ -84,6 +85,8 @@ public static function getType(string $target): string } /** + * Returns a list of all known source types. + * * @return array|string[] */ public static function getAllSourceTypes(): array diff --git a/src/Twig/Extension/AppExtension.php b/src/Twig/Extension/AppExtension.php index 629b4539..1a252a6b 100644 --- a/src/Twig/Extension/AppExtension.php +++ b/src/Twig/Extension/AppExtension.php @@ -44,6 +44,7 @@ public function getFunctions(): array new TwigFunction('choice_attr', [$this->runtime, 'getChoiceAttribute']), new TwigFunction('app_version', [$this->runtime, 'appVersion']), new TwigFunction('die', [$this->runtime, 'dieTwig']), + new TwigFunction('is_backend', [$this->runtime, 'isBackend']), ]; } } diff --git a/src/Twig/Extension/DateExtension.php b/src/Twig/Extension/DateExtension.php index 088bf232..e0ee62f0 100644 --- a/src/Twig/Extension/DateExtension.php +++ b/src/Twig/Extension/DateExtension.php @@ -19,7 +19,7 @@ public function getFunctions(): array /** * Validates whether a date string conforms to a specified format. */ - public function isValidDate(?string $date, string $format = 'd-m-Y'): bool + public function isValidDate(?string $date, string $format = 'Y-m-d'): bool { if (! $date) { return false; diff --git a/src/Twig/Extension/WooExtension.php b/src/Twig/Extension/WooExtension.php index 4c5622e2..0d4e6bb6 100644 --- a/src/Twig/Extension/WooExtension.php +++ b/src/Twig/Extension/WooExtension.php @@ -39,6 +39,10 @@ public function getFunctions(): array new TwigFunction('status_badge', [$this->runtime, 'statusBadge'], ['is_safe' => ['html']]), new TwigFunction('period', [$this->runtime, 'period']), new TwigFunction('has_thumbnail', [$this->runtime, 'hasThumbnail']), + new TwigFunction('is_document_id', [$this->runtime, 'isDocumentLink']), + new TwigFunction('generate_document_link', [$this->runtime, 'generateDocumentLink']), + new TwigFunction('get_citation_type', [$this->runtime, 'getCitationType']), + new TwigFunction('query_string_without_param', [$this->runtime, 'queryStringWithoutParam']), ]; } } diff --git a/src/Twig/Runtime/AppExtensionRuntime.php b/src/Twig/Runtime/AppExtensionRuntime.php index a23e2d37..51a3e98a 100644 --- a/src/Twig/Runtime/AppExtensionRuntime.php +++ b/src/Twig/Runtime/AppExtensionRuntime.php @@ -6,15 +6,19 @@ use Carbon\Carbon; use Symfony\Component\Form\FormView; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Extension\RuntimeExtensionInterface; class AppExtensionRuntime implements RuntimeExtensionInterface { protected string $projectPath; + protected RequestStack $requestStack; - public function __construct(string $projectPath) + public function __construct(string $projectPath, RequestStack $requestStack) { $this->projectPath = $projectPath; + $this->requestStack = $requestStack; } public function basename(string $value): string @@ -77,4 +81,11 @@ public function isInstanceOf(mixed $var, string $instance): bool { return $var instanceof $instance; } + + public function isBackend(): bool + { + $request = $this->requestStack->getCurrentRequest() ?? new Request(); + + return str_starts_with($request->getPathInfo(), '/balie'); + } } diff --git a/src/Twig/Runtime/WooExtensionRuntime.php b/src/Twig/Runtime/WooExtensionRuntime.php index 863ef3ab..4fcda66e 100644 --- a/src/Twig/Runtime/WooExtensionRuntime.php +++ b/src/Twig/Runtime/WooExtensionRuntime.php @@ -7,27 +7,41 @@ use App\Citation; use App\Entity\Document; use App\Entity\Dossier; +use App\Repository\DocumentRepository; use App\Service\DateRangeConverter; -use App\Service\Search\Model\Facet; +use App\Service\Search\Query\Facet\FacetMappingService; use App\Service\Storage\ThumbnailStorageService; use App\SourceType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Twig\Extension\RuntimeExtensionInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class WooExtensionRuntime implements RuntimeExtensionInterface { protected RequestStack $requestStack; protected ThumbnailStorageService $storageService; + protected DocumentRepository $documentRepository; + protected UrlGeneratorInterface $urlGenerator; + protected TranslatorInterface $translator; - private TranslatorInterface $translator; - - public function __construct(RequestStack $requestStack, ThumbnailStorageService $storageService, TranslatorInterface $translator) - { + public function __construct( + RequestStack $requestStack, + ThumbnailStorageService $storageService, + TranslatorInterface $translator, + DocumentRepository $documentRepository, + UrlGeneratorInterface $urlGenerator, + private readonly FacetMappingService $facetMapping, + ) { $this->requestStack = $requestStack; $this->storageService = $storageService; $this->translator = $translator; + $this->documentRepository = $documentRepository; + $this->urlGenerator = $urlGenerator; } /** @@ -104,7 +118,7 @@ public function decision(string $value): string case Dossier::DECISION_NOTHING_FOUND: return 'Niets gevonden'; case Dossier::DECISION_PARTIAL_PUBLIC: - return 'Gedeeltelijk gepubliceerd'; + return 'Deels openbaar'; default: return 'Onbekend'; @@ -140,8 +154,8 @@ public function period(?\DateTimeImmutable $from, ?\DateTimeImmutable $to): stri */ public function hasFacets(Request $request): bool { - foreach (Facet::getQueryMapping() as $queryKey) { - if ($request->query->has($queryKey)) { + foreach ($this->facetMapping->getAll() as $defition) { + if ($request->query->has($defition->getQueryParam())) { return true; } } @@ -162,6 +176,98 @@ public function hasThumbnail(Document $document, int $pageNr): bool */ public function facet2query(string $facet): string { - return Facet::getQueryVarForFacet($facet); + return $this->facetMapping->getFacetByKey($facet)->getQueryParam(); + } + + /** + * Returns true if the given link is actually a document ID (ie: PREFIX-12345) and the given dossier is set to published. + */ + public function isDocumentLink(string $link): bool + { + /** @var Document|null $document */ + $document = $this->documentRepository->findOneBy(['documentNr' => $link]); + if (is_null($document)) { + return false; + } + + // If we find a dossier with status published, we can return true + foreach ($document->getDossiers() as $dossier) { + if ($dossier->getStatus() == Dossier::STATUS_PUBLISHED) { + return true; + } + } + + return false; + } + + /** + * Generate the link from the given document ID found in the link. If the link is not an existing document id, it will be returned as is. + */ + public function generateDocumentLink(string $link): string + { + /** @var Document|null $document */ + $document = $this->documentRepository->findOneBy(['documentNr' => $link]); + if (is_null($document)) { + return $link; + } + + // If we find a dossier with status published, we can return true + foreach ($document->getDossiers() as $dossier) { + if ($dossier->getStatus() == Dossier::STATUS_PUBLISHED) { + return $this->urlGenerator->generate('app_document_detail', [ + 'dossierId' => $dossier->getDossierNr(), + 'documentId' => $document->getDocumentNr(), + ]); + } + } + + return $link; + } + + public function getCitationType(string $citation): string + { + return Citation::getCitationType($citation); + } + + public function queryStringWithoutParam(string $queryParam, string $value): string + { + $request = $this->requestStack->getCurrentRequest(); + if (! $request) { + return ''; + } + + $queryString = strval($request->getQueryString()); + parse_str($queryString, $currentParams); + parse_str($queryParam, $paramToRemove); + $paramKeyToRemove = key($paramToRemove); + + $currentParamValue = $currentParams[$paramKeyToRemove] ?? null; + if ($currentParamValue === null) { + return $queryString; + } + + if (is_array($currentParamValue)) { + if (array_is_list($currentParamValue)) { + foreach ($currentParamValue as $paramSubKey => $paramSubValue) { + if ($paramSubValue === $value) { + unset($currentParams[$paramKeyToRemove][$paramSubKey]); + break; + } + } + } else { + /** @var array> $paramToRemove */ + $paramSubKey = key($paramToRemove[$paramKeyToRemove]); + unset($currentParams[$paramKeyToRemove][$paramSubKey]); + } + } else { + unset($currentParams[$paramKeyToRemove]); + } + + $currentParams = array_filter($currentParams); + + $query = http_build_query($currentParams); + $query = preg_replace('/%5B\d+%5D/imU', '%5B%5D', $query); + + return strval($query); } } diff --git a/symfony.lock b/symfony.lock index c504d6f5..9ea87014 100644 --- a/symfony.lock +++ b/symfony.lock @@ -116,6 +116,9 @@ "tests/bootstrap.php" ] }, + "presta/sitemap-bundle": { + "version": "v3.3.1" + }, "scheb/2fa-bundle": { "version": "6.8", "recipe": { diff --git a/tailwind.config.js b/tailwind.config.js index b311805c..76abbd6e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,11 +1,18 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ - "./templates/**/*.html.twig", - ], - theme: { - extend: {}, - }, - plugins: [], + content: [ + "./templates/**/*.html.twig", + ], + theme: { + extend: { + // https://www.color-name.com/hex/f3f3f3 + colors: { + 'anti-flash-white': '#f3f3f3', + 'dim-gray': '#696969', + independence: '#475467' + }, + }, + }, + plugins: [], } diff --git a/templates/admin.html.twig b/templates/admin.html.twig index b9fca817..7bf15197 100644 --- a/templates/admin.html.twig +++ b/templates/admin.html.twig @@ -5,7 +5,7 @@ {% block meta %}{% endblock %} - Admin + Beheer {{ SITE_NAME }} @@ -14,15 +14,62 @@ {{ encore_entry_script_tags('woopie') }} {{ encore_entry_script_tags('admin') }} + + {% include "piwik.html.twig" %}

- WobCovid Publicatie/document management + + {%- block header_content -%} +
+ {% block page_title %}

{{ 'Admin' | trans() }}

{% endblock %} + {% block actions %}{% endblock %} +
+ {%- endblock -%}
+ {% block body %}{% endblock %}