diff --git a/composer.json b/composer.json index f4dc2fa..10217de 100644 --- a/composer.json +++ b/composer.json @@ -16,15 +16,19 @@ ], "require": { "composer/installers": "^2.0", + "drupal/auto_entitylabel": "^3.4", "drupal/cer": "^5.0@beta", "drupal/config_ignore": "^3.4", "drupal/core-composer-scaffold": "^10.6", "drupal/core-project-message": "^10.6", "drupal/core-recommended": "^10.6", + "drupal/image_delta_formatter": "^1.3", + "drupal/image_field_caption": "^3.0", "drupal/migrate_google_sheets": "^2.3", "drupal/migrate_tools": "^6.1", "drupal/page_manager": "^4.0@RC", "drupal/shield": "^1.8", + "league/commonmark": "^2.8", "wikimedia/composer-merge-plugin": "^2.1" }, "conflict": { diff --git a/composer.lock b/composer.lock index a401432..a3b9ec8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0b0b3210b893eaaa833b27f1e6fae4c9", + "content-hash": "5bd9a2694b1628f45febf28ca3f995d7", "packages": [ { "name": "asm89/stack-cors", @@ -1868,6 +1868,87 @@ "issues": "https://www.drupal.org/project/issues/audiofield" } }, + { + "name": "drupal/auto_entitylabel", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/auto_entitylabel.git", + "reference": "8.x-3.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/auto_entitylabel-8.x-3.4.zip", + "reference": "8.x-3.4", + "shasum": "9b3d7bc8e450ae008b3f48fed0dd9dace03ddbb5" + }, + "require": { + "drupal/core": "^10.1 || ^11" + }, + "require-dev": { + "drupal/book": "^1.0", + "drupal/token": "^1.0" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "8.x-3.4", + "datestamp": "1736308389", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "bforchhammer", + "homepage": "https://www.drupal.org/user/216396" + }, + { + "name": "colan", + "homepage": "https://www.drupal.org/user/58704" + }, + { + "name": "dqd", + "homepage": "https://www.drupal.org/user/1001934" + }, + { + "name": "japerry", + "homepage": "https://www.drupal.org/user/45640" + }, + { + "name": "mandclu", + "homepage": "https://www.drupal.org/user/52136" + }, + { + "name": "pravin ajaaz", + "homepage": "https://www.drupal.org/user/2910049" + }, + { + "name": "purushotam.rai", + "homepage": "https://www.drupal.org/user/3193859" + }, + { + "name": "renatog", + "homepage": "https://www.drupal.org/user/3326031" + }, + { + "name": "vladimiraus", + "homepage": "https://www.drupal.org/user/673120" + } + ], + "description": "Allows hiding of entity label fields and automatic label creation.", + "homepage": "https://www.drupal.org/project/auto_entitylabel", + "support": { + "source": "https://git.drupalcode.org/project/auto_entitylabel", + "issues": "https://www.drupal.org/project/issues/auto_entitylabel" + } + }, { "name": "drupal/autocomplete_deluxe", "version": "2.1.2", @@ -4243,6 +4324,131 @@ "issues": "https://www.drupal.org/project/issues/honeypot" } }, + { + "name": "drupal/image_delta_formatter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/image_delta_formatter.git", + "reference": "8.x-1.3" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/image_delta_formatter-8.x-1.3.zip", + "reference": "8.x-1.3", + "shasum": "6ec48166cb04eda57915dbe28f97fa32c1e77b75" + }, + "require": { + "drupal/core": "^10 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "8.x-1.3", + "datestamp": "1738516937", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "bojanz", + "homepage": "https://www.drupal.org/user/86106" + }, + { + "name": "fgm", + "homepage": "https://www.drupal.org/user/27985" + }, + { + "name": "jsacksick", + "homepage": "https://www.drupal.org/user/972218" + } + ], + "description": "Provides a formatter that displays a specific delta of a multi-value image or media field.", + "homepage": "https://www.drupal.org/project/image_delta_formatter", + "support": { + "source": "https://git.drupalcode.org/project/image_delta_formatter" + } + }, + { + "name": "drupal/image_field_caption", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/image_field_caption.git", + "reference": "3.0.1" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/image_field_caption-3.0.1.zip", + "reference": "3.0.1", + "shasum": "e84c3b9534d5824b6a67885187b1c24d4eccaf60" + }, + "require": { + "drupal/core": "^10 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "3.0.1", + "datestamp": "1739400507", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "awm", + "homepage": "https://www.drupal.org/user/1059398" + }, + { + "name": "foxy-vikvik", + "homepage": "https://www.drupal.org/user/3706169" + }, + { + "name": "hanoii", + "homepage": "https://www.drupal.org/user/23157" + }, + { + "name": "ironsizide", + "homepage": "https://www.drupal.org/user/787980" + }, + { + "name": "istryker", + "homepage": "https://www.drupal.org/user/303676" + }, + { + "name": "kksandr", + "homepage": "https://www.drupal.org/user/3710922" + }, + { + "name": "robloach", + "homepage": "https://www.drupal.org/user/61114" + }, + { + "name": "tyler.frankenstein", + "homepage": "https://www.drupal.org/user/150680" + } + ], + "description": "Add caption to image field.", + "homepage": "https://www.drupal.org/project/image_field_caption", + "support": { + "source": "https://git.drupalcode.org/project/image_field_caption", + "issues": "https://www.drupal.org/project/issues/image_field_caption" + } + }, { "name": "drupal/imagemagick", "version": "3.7.0", @@ -7864,6 +8070,195 @@ }, "time": "2026-06-26T00:11:25+00:00" }, + { + "name": "league/commonmark", + "version": "2.8.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-07-12T15:29:16+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, { "name": "league/container", "version": "4.2.5", @@ -8062,6 +8457,164 @@ }, "time": "2026-04-24T08:04:05+00:00" }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, { "name": "nikic/php-parser", "version": "v5.8.0", @@ -10687,6 +11240,90 @@ ], "time": "2026-04-10T17:25:58+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/polyfill-php81", "version": "v1.38.1", diff --git a/config/sync/auto_entitylabel.settings.node.personne.yml b/config/sync/auto_entitylabel.settings.node.personne.yml new file mode 100644 index 0000000..e4d6b9a --- /dev/null +++ b/config/sync/auto_entitylabel.settings.node.personne.yml @@ -0,0 +1,10 @@ +status: 1 +pattern: '[node:field_prenom] [node:field_nom]' +escape: false +preserve_titles: false +save: true +chunk: 50 +dependencies: + config: + - node.type.personne +new_content_behavior: 0 diff --git a/config/sync/cer.corresponding_reference.projet_curateur.yml b/config/sync/cer.corresponding_reference.projet_curateur.yml index e9e435a..b3265c6 100644 --- a/config/sync/cer.corresponding_reference.projet_curateur.yml +++ b/config/sync/cer.corresponding_reference.projet_curateur.yml @@ -6,7 +6,7 @@ id: projet_curateur label: 'Projet - Curateur' enabled: true first_field: field_invite_accompagne_curate_p -second_field: field_projet_s_lie_s +second_field: field_projets_curates add_direction: append bundles: node: diff --git a/config/sync/core.base_field_override.node.personne.title.yml b/config/sync/core.base_field_override.node.personne.title.yml index 9c72878..eb1916c 100644 --- a/config/sync/core.base_field_override.node.personne.title.yml +++ b/config/sync/core.base_field_override.node.personne.title.yml @@ -8,7 +8,7 @@ id: node.personne.title field_name: title entity_type: node bundle: personne -label: Nom +label: Title description: '' required: true translatable: true diff --git a/config/sync/core.entity_form_display.node.evenement.default.yml b/config/sync/core.entity_form_display.node.evenement.default.yml index 2c7be5d..cd6b1b8 100644 --- a/config/sync/core.entity_form_display.node.evenement.default.yml +++ b/config/sync/core.entity_form_display.node.evenement.default.yml @@ -162,4 +162,9 @@ content: size: 60 placeholder: '' third_party_settings: { } + url_redirects: + weight: 50 + region: content + settings: { } + third_party_settings: { } hidden: { } diff --git a/config/sync/core.entity_form_display.node.lieu.default.yml b/config/sync/core.entity_form_display.node.lieu.default.yml index 2b65f78..e691682 100644 --- a/config/sync/core.entity_form_display.node.lieu.default.yml +++ b/config/sync/core.entity_form_display.node.lieu.default.yml @@ -89,4 +89,9 @@ content: size: 60 placeholder: '' third_party_settings: { } + url_redirects: + weight: 50 + region: content + settings: { } + third_party_settings: { } hidden: { } diff --git a/config/sync/core.entity_form_display.node.personne.default.yml b/config/sync/core.entity_form_display.node.personne.default.yml index 7b471a7..4771146 100644 --- a/config/sync/core.entity_form_display.node.personne.default.yml +++ b/config/sync/core.entity_form_display.node.personne.default.yml @@ -4,8 +4,10 @@ status: true dependencies: config: - field.field.node.personne.body + - field.field.node.personne.field_nom - field.field.node.personne.field_prenom - field.field.node.personne.field_projet_s_lie_s + - field.field.node.personne.field_projets_curates - field.field.node.personne.field_type_de_personne - node.type.personne module: @@ -23,7 +25,7 @@ third_party_settings: label: Details region: content parent_name: '' - weight: 4 + weight: 5 format_type: details_sidebar format_settings: classes: '' @@ -41,7 +43,7 @@ mode: default content: body: type: text_textarea_with_summary - weight: 2 + weight: 3 region: content settings: rows: 9 @@ -49,6 +51,14 @@ content: placeholder: '' show_summary: false third_party_settings: { } + field_nom: + type: string_textfield + weight: 2 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } field_prenom: type: string_textfield weight: 1 @@ -59,7 +69,17 @@ content: third_party_settings: { } field_projet_s_lie_s: type: entity_reference_autocomplete - weight: 3 + weight: 4 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + field_projets_curates: + type: entity_reference_autocomplete + weight: 26 region: content settings: match_operator: CONTAINS diff --git a/config/sync/core.entity_form_display.node.projet.default.yml b/config/sync/core.entity_form_display.node.projet.default.yml index 547cc43..2d0e595 100644 --- a/config/sync/core.entity_form_display.node.projet.default.yml +++ b/config/sync/core.entity_form_display.node.projet.default.yml @@ -257,6 +257,11 @@ content: size: 60 placeholder: '' third_party_settings: { } + url_redirects: + weight: 50 + region: content + settings: { } + third_party_settings: { } hidden: created: true sticky: true diff --git a/config/sync/core.entity_view_display.node.personne.default.yml b/config/sync/core.entity_view_display.node.personne.default.yml index 664fbc6..dc8a491 100644 --- a/config/sync/core.entity_view_display.node.personne.default.yml +++ b/config/sync/core.entity_view_display.node.personne.default.yml @@ -4,8 +4,10 @@ status: true dependencies: config: - field.field.node.personne.body + - field.field.node.personne.field_nom - field.field.node.personne.field_prenom - field.field.node.personne.field_projet_s_lie_s + - field.field.node.personne.field_projets_curates - field.field.node.personne.field_type_de_personne - node.type.personne module: @@ -23,6 +25,14 @@ content: third_party_settings: { } weight: 101 region: content + field_nom: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: 109 + region: content field_prenom: type: string label: above @@ -39,6 +49,14 @@ content: third_party_settings: { } weight: 108 region: content + field_projets_curates: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 110 + region: content field_type_de_personne: type: entity_reference_label label: above diff --git a/config/sync/core.entity_view_display.node.personne.teaser.yml b/config/sync/core.entity_view_display.node.personne.teaser.yml index 821cc2f..902754f 100644 --- a/config/sync/core.entity_view_display.node.personne.teaser.yml +++ b/config/sync/core.entity_view_display.node.personne.teaser.yml @@ -5,34 +5,26 @@ dependencies: config: - core.entity_view_mode.node.teaser - field.field.node.personne.body + - field.field.node.personne.field_nom - field.field.node.personne.field_prenom - field.field.node.personne.field_projet_s_lie_s + - field.field.node.personne.field_projets_curates - field.field.node.personne.field_type_de_personne - node.type.personne module: - - text - user id: node.personne.teaser targetEntityType: node bundle: personne mode: teaser -content: - body: - type: text_summary_or_trimmed - label: hidden - settings: - trim_length: 600 - third_party_settings: { } - weight: 101 - region: content - links: - settings: { } - third_party_settings: { } - weight: 100 - region: content +content: { } hidden: + body: true + field_nom: true field_prenom: true field_projet_s_lie_s: true + field_projets_curates: true field_type_de_personne: true langcode: true + links: true search_api_excerpt: true diff --git a/config/sync/core.entity_view_display.node.projet.teaser.yml b/config/sync/core.entity_view_display.node.projet.teaser.yml index 1812551..6f3c53d 100644 --- a/config/sync/core.entity_view_display.node.projet.teaser.yml +++ b/config/sync/core.entity_view_display.node.projet.teaser.yml @@ -13,8 +13,10 @@ dependencies: - field.field.node.projet.field_portage - field.field.node.projet.field_projet_s_lie_s - field.field.node.projet.field_type_de_projet + - image.style.medium - node.type.projet module: + - image_delta_formatter - text - user id: node.projet.teaser @@ -26,23 +28,40 @@ content: type: text_summary_or_trimmed label: hidden settings: - trim_length: 600 + trim_length: 100 third_party_settings: { } - weight: 101 + weight: 2 region: content - links: - settings: { } + field_artiste_s_invite_s: + type: entity_reference_entity_view + label: hidden + settings: + view_mode: teaser + link: false third_party_settings: { } - weight: 100 + weight: 1 + region: content + field_images: + type: image_delta_formatter + label: hidden + settings: + deltas: + - 1 + deltas_reversed: false + image_link: content + image_style: medium + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 region: content hidden: - field_artiste_s_invite_s: true field_dates: true - field_images: true field_invite_accompagne_curate_p: true field_partenaire_s: true field_portage: true field_projet_s_lie_s: true field_type_de_projet: true langcode: true + links: true search_api_excerpt: true diff --git a/config/sync/core.extension.yml b/config/sync/core.extension.yml index e213ce8..03f0f61 100644 --- a/config/sync/core.extension.yml +++ b/config/sync/core.extension.yml @@ -1,6 +1,7 @@ _core: default_config_hash: 4GIX5Esnc_umpXUBj4IIocRX7Mt5fPhm4AgXfE3E56E module: + auto_entitylabel: -100 actions_permissions: 0 address: 0 admin_toolbar: 0 @@ -52,6 +53,8 @@ module: help: 0 honeypot: 0 image: 0 + image_delta_formatter: 0 + image_field_caption: 0 inline_entity_form: 0 language: 0 layout_builder: 0 diff --git a/config/sync/field.field.node.personne.field_nom.yml b/config/sync/field.field.node.personne.field_nom.yml new file mode 100644 index 0000000..58acaf4 --- /dev/null +++ b/config/sync/field.field.node.personne.field_nom.yml @@ -0,0 +1,19 @@ +uuid: 7f1a16d3-57ea-4861-ad4f-1312ceadd1e7 +langcode: fr +status: true +dependencies: + config: + - field.storage.node.field_nom + - node.type.personne +id: node.personne.field_nom +field_name: field_nom +entity_type: node +bundle: personne +label: Nom +description: '' +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/sync/field.field.node.personne.field_prenom.yml b/config/sync/field.field.node.personne.field_prenom.yml index 2a9ed69..a778d46 100644 --- a/config/sync/field.field.node.personne.field_prenom.yml +++ b/config/sync/field.field.node.personne.field_prenom.yml @@ -11,7 +11,7 @@ entity_type: node bundle: personne label: Prenom description: '' -required: false +required: true translatable: false default_value: { } default_value_callback: '' diff --git a/config/sync/field.field.node.personne.field_projets_curates.yml b/config/sync/field.field.node.personne.field_projets_curates.yml new file mode 100644 index 0000000..ccd1f51 --- /dev/null +++ b/config/sync/field.field.node.personne.field_projets_curates.yml @@ -0,0 +1,29 @@ +uuid: 75b15206-7946-48cb-b7b9-17c27ad00c25 +langcode: fr +status: true +dependencies: + config: + - field.storage.node.field_projets_curates + - node.type.personne + - node.type.projet +id: node.personne.field_projets_curates +field_name: field_projets_curates +entity_type: node +bundle: personne +label: projets_curates +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:node' + handler_settings: + target_bundles: + projet: projet + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/config/sync/field.field.node.projet.field_images.yml b/config/sync/field.field.node.projet.field_images.yml index 3df400c..2cbe5d1 100644 --- a/config/sync/field.field.node.projet.field_images.yml +++ b/config/sync/field.field.node.projet.field_images.yml @@ -35,4 +35,7 @@ settings: title: '' width: null height: null + caption: '' + caption_field: 1 + caption_field_required: 0 field_type: image diff --git a/config/sync/field.storage.node.field_nom.yml b/config/sync/field.storage.node.field_nom.yml new file mode 100644 index 0000000..d725a4d --- /dev/null +++ b/config/sync/field.storage.node.field_nom.yml @@ -0,0 +1,21 @@ +uuid: 14aa5aef-0840-4e3d-a78a-027e2c61844f +langcode: fr +status: true +dependencies: + module: + - node +id: node.field_nom +field_name: field_nom +entity_type: node +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/sync/field.storage.node.field_projets_curates.yml b/config/sync/field.storage.node.field_projets_curates.yml new file mode 100644 index 0000000..ca55bc4 --- /dev/null +++ b/config/sync/field.storage.node.field_projets_curates.yml @@ -0,0 +1,19 @@ +uuid: c5d97546-10d2-46e0-9324-1164bdd04cbb +langcode: fr +status: true +dependencies: + module: + - node +id: node.field_projets_curates +field_name: field_projets_curates +entity_type: node +type: entity_reference +settings: + target_type: node +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/sync/migrate_plus.migration.projet_node.yml b/config/sync/migrate_plus.migration.projet_node.yml index 14e94d3..f7d1414 100644 --- a/config/sync/migrate_plus.migration.projet_node.yml +++ b/config/sync/migrate_plus.migration.projet_node.yml @@ -15,6 +15,8 @@ source: plugin: projet_node data_fetcher_plugin: http data_parser_plugin: google_sheets + constants: + images_destination: 'public://projets/' urls: 'https://sheets.googleapis.com/v4/spreadsheets/1561Gv1C-IlCTmg64Z8HnznvOeLbvwhA_k1l6aq95zFA/values/Projets' fields: - @@ -56,7 +58,32 @@ process: type: plugin: default_value default_value: projet - title: title + title: + plugin: skip_on_empty + method: row + source: title + message: 'Projet ignoré : « Nom du projet » vide.' + body/value: body_html + body/format: + plugin: default_value + default_value: wysiwyg + field_images: + plugin: sub_process + source: images + process: + target_id: + plugin: image_resize_import + source: uri + destination: destination + max_width: 1920 + max_height: 1920 + reuse: true + skip_on_missing_source: true + alt: alt + image_field_caption/value: caption + image_field_caption/format: + plugin: default_value + default_value: wysiwyg field_dates/value: plugin: leshed_date source: date_start diff --git a/config/sync/redirect.settings.yml b/config/sync/redirect.settings.yml index 41893a7..cb6a11d 100644 --- a/config/sync/redirect.settings.yml +++ b/config/sync/redirect.settings.yml @@ -1,3 +1,4 @@ +langcode: fr _core: default_config_hash: R7HmKRbuDsDQNpqEWt7wWcf0ygaE3u-NQQr-w_LhHJk auto_redirect: true @@ -7,4 +8,3 @@ warning: false ignore_admin_path: false access_check: false route_normalizer_enabled: true -langcode: fr diff --git a/config/sync/views.view.content.yml b/config/sync/views.view.content.yml index 753c27b..aef4edb 100644 --- a/config/sync/views.view.content.yml +++ b/config/sync/views.view.content.yml @@ -3,7 +3,8 @@ langcode: fr status: true dependencies: config: - - field.storage.node.field_prenom + - field.storage.node.field_projet_s_lie_s + - field.storage.node.field_projets_curates - field.storage.node.field_type_de_projet module: - node @@ -107,69 +108,6 @@ display: multi_type: separator separator: ', ' field_api_classes: false - field_prenom: - id: field_prenom - table: node__field_prenom - field: field_prenom - relationship: none - group_type: group - admin_label: '' - plugin_id: field - label: '' - exclude: true - alter: - alter_text: false - text: '' - make_link: false - path: '' - absolute: false - external: false - replace_spaces: false - path_case: none - trim_whitespace: false - alt: '' - rel: '' - link_class: '' - prefix: '' - suffix: '' - target: '' - nl2br: false - max_length: 0 - word_boundary: true - ellipsis: true - more_link: false - more_link_text: '' - more_link_path: '' - strip_tags: false - trim: false - preserve_tags: '' - html: false - element_type: '' - element_class: '' - element_label_type: '' - element_label_class: '' - element_label_colon: false - element_wrapper_type: '' - element_wrapper_class: '' - element_default_classes: true - empty: '' - hide_empty: false - empty_zero: false - hide_alter_empty: true - click_sort_column: value - type: string - settings: - link_to_entity: true - group_column: value - group_columns: { } - group_rows: true - delta_limit: 0 - delta_offset: 0 - delta_reversed: false - delta_first_last: false - multi_type: separator - separator: ', ' - field_api_classes: false title: id: title table: node_field_data @@ -183,7 +121,7 @@ display: label: Titre exclude: false alter: - alter_text: true + alter_text: false text: '{{ field_prenom }} {{ title }}' make_link: false path: '' @@ -298,25 +236,132 @@ display: multi_type: separator separator: ', ' field_api_classes: false - name: - id: name - table: users_field_data - field: name - relationship: uid - entity_type: user - entity_field: name + field_projet_s_lie_s: + id: field_projet_s_lie_s + table: node__field_projet_s_lie_s + field: field_projet_s_lie_s + relationship: none + group_type: group + admin_label: '' plugin_id: field - label: Auteur + label: 'Projet(s) lié(s)' exclude: false alter: alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' element_default_classes: true empty: '' hide_empty: false empty_zero: false hide_alter_empty: true - type: user_name + click_sort_column: target_id + type: entity_reference_label + settings: + link: true + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + field_projets_curates: + id: field_projets_curates + table: node__field_projets_curates + field: field_projets_curates + relationship: none + group_type: group + admin_label: '' + plugin_id: field + label: projets_curates + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_label + settings: + link: true + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false status: id: status table: node_field_data @@ -658,9 +703,10 @@ display: columns: node_bulk_form: node_bulk_form type: title - field_prenom: title title: title - name: name + field_type_de_projet: field_type_de_projet + field_projets_curates: field_projet_s_lie_s + field_projet_s_lie_s: field_projet_s_lie_s status: status changed: changed operations: operations @@ -678,13 +724,6 @@ display: separator: '' empty_column: false responsive: '' - field_prenom: - sortable: false - default_sort_order: asc - align: '' - separator: '' - empty_column: false - responsive: '' title: sortable: true default_sort_order: asc @@ -692,13 +731,21 @@ display: separator: '
' empty_column: false responsive: '' - name: - sortable: false - default_sort_order: asc + field_type_de_projet: align: '' separator: '' empty_column: false - responsive: priority-low + responsive: '' + field_projets_curates: + align: '' + separator: '' + empty_column: false + responsive: '' + field_projet_s_lie_s: + align: '' + separator: '' + empty_column: false + responsive: '' status: sortable: true default_sort_order: asc @@ -737,6 +784,17 @@ display: plugin_id: standard required: true show_admin_links: false + header: + result: + id: result + table: views + field: result + relationship: none + group_type: group + admin_label: '' + plugin_id: result + empty: false + content: 'Affichage de @start à @end sur @total' display_extenders: { } cache_metadata: max-age: 0 @@ -749,7 +807,8 @@ display: - 'user.node_grants:view' - user.permissions tags: - - 'config:field.storage.node.field_prenom' + - 'config:field.storage.node.field_projet_s_lie_s' + - 'config:field.storage.node.field_projets_curates' - 'config:field.storage.node.field_type_de_projet' page_1: id: page_1 @@ -783,5 +842,6 @@ display: - 'user.node_grants:view' - user.permissions tags: - - 'config:field.storage.node.field_prenom' + - 'config:field.storage.node.field_projet_s_lie_s' + - 'config:field.storage.node.field_projets_curates' - 'config:field.storage.node.field_type_de_projet' diff --git a/web/modules/custom/migrate_leshed/config/install/migrate_plus.migration.projet_node.yml b/web/modules/custom/migrate_leshed/config/install/migrate_plus.migration.projet_node.yml index 6536505..51f68d6 100644 --- a/web/modules/custom/migrate_leshed/config/install/migrate_plus.migration.projet_node.yml +++ b/web/modules/custom/migrate_leshed/config/install/migrate_plus.migration.projet_node.yml @@ -10,6 +10,10 @@ source: plugin: projet_node data_fetcher_plugin: http data_parser_plugin: google_sheets + constants: + # Trailing slash: file_import keeps the original filename and copies the + # image into this directory. + images_destination: 'public://projets/' # The feed file for the spreadsheet. The Google Spreadsheet should be either “Public” or set to “Anyone with link can # view” in order for the feed to work. # Template: 'https://sheets.googleapis.com/v4/spreadsheets//values/?key=' @@ -68,7 +72,32 @@ process: type: plugin: default_value default_value: projet - title: title + title: + plugin: skip_on_empty + method: row + source: title + message: 'Projet ignoré : « Nom du projet » vide.' + body/value: body_html + body/format: + plugin: default_value + default_value: wysiwyg + field_images: + plugin: sub_process + source: images + process: + target_id: + plugin: image_resize_import + source: uri + destination: destination + max_width: 1920 + max_height: 1920 + reuse: true + skip_on_missing_source: true + alt: alt + image_field_caption/value: caption + image_field_caption/format: + plugin: default_value + default_value: wysiwyg field_dates/value: plugin: leshed_date source: date_start diff --git a/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/ImageResizeImport.php b/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/ImageResizeImport.php new file mode 100644 index 0000000..1943226 --- /dev/null +++ b/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/ImageResizeImport.php @@ -0,0 +1,179 @@ + + */ + private array $fidCache = []; + + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + private readonly FileSystemInterface $fileSystem, + private readonly ImageFactory $imageFactory, + private readonly EntityTypeManagerInterface $entityTypeManager, + ) { + $configuration += [ + 'max_width' => 1920, + 'max_height' => 1920, + 'uid' => 0, + 'reuse' => TRUE, + 'skip_on_missing_source' => TRUE, + ]; + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self { + return new self( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('file_system'), + $container->get('image.factory'), + $container->get('entity_type.manager'), + ); + } + + /** + * {@inheritdoc} + */ + public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): ?int { + $source = is_string($value) ? trim($value) : ''; + if ($source === '') { + return NULL; + } + + // Stream wrappers support is_file() directly; realpath() is only needed as + // a fallback for schemes it cannot resolve. + if (!is_file($source) && !is_file($this->fileSystem->realpath($source) ?: $source)) { + // Skipping throws MigrateSkipRowException because, inside a sub_process, + // only that exception drops the whole image item cleanly (SubProcess + // catches it and `continue`s); MigrateSkipProcessException would instead + // leave a broken delta with alt/caption but no target_id. + return $this->skipImage($migrate_executable, "Source image '$source' is missing."); + } + + $directory = $this->getPropertyValue($this->configuration['destination'], $row); + if (!is_string($directory) || $directory === '') { + return $this->skipImage($migrate_executable, "Missing 'destination' for image '$source'."); + } + $directory = rtrim($directory, '/') . '/'; + $destination = $directory . $this->fileSystem->basename($source); + + // Reuse an already imported file (idempotency), memoized for the run. + if ($this->configuration['reuse']) { + if (isset($this->fidCache[$destination])) { + return $this->fidCache[$destination]; + } + $existing = $this->entityTypeManager->getStorage('file') + ->loadByProperties(['uri' => $destination]); + if ($existing) { + return $this->fidCache[$destination] = (int) reset($existing)->id(); + } + } + + // Any infrastructure failure only drops the current image (logged), so a + // single unreadable/oversized file never aborts the whole batch. + try { + if (!$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) { + return $this->skipImage($migrate_executable, "Cannot prepare directory '$directory'."); + } + + $image = $this->imageFactory->get($source); + if (!$image->isValid()) { + return $this->skipImage($migrate_executable, "Source '$source' is not a valid image."); + } + // Downscale only: scale() never upscales when upscale is FALSE. + $image->scale((int) $this->configuration['max_width'], (int) $this->configuration['max_height']); + if (!$image->save($destination)) { + return $this->skipImage($migrate_executable, "Failed to write resized image to '$destination'."); + } + + $file = $this->entityTypeManager->getStorage('file')->create([ + 'uri' => $destination, + 'uid' => (int) $this->getPropertyValue($this->configuration['uid'], $row), + 'status' => 1, + ]); + $file->save(); + } + catch (\Throwable $e) { + return $this->skipImage($migrate_executable, "Import of image '$source' failed: " . $e->getMessage()); + } + + return $this->fidCache[$destination] = (int) $file->id(); + } + + /** + * Logs a message and skips the current image item. + * + * @throws \Drupal\migrate\MigrateSkipRowException + */ + private function skipImage(MigrateExecutableInterface $migrate_executable, string $message): never { + $migrate_executable->saveMessage($message); + throw new MigrateSkipRowException($message, FALSE); + } + + /** + * Resolves a configuration value, supporting destination (@) references. + */ + private function getPropertyValue(mixed $key, Row $row): mixed { + if (is_string($key) && str_starts_with($key, '@')) { + return $row->get(substr($key, 1)); + } + if (is_string($key) && $row->hasSourceProperty($key)) { + return $row->getSourceProperty($key); + } + + return $key; + } + +} diff --git a/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/PersonneGenerate.php b/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/PersonneGenerate.php index 55734d9..f572718 100644 --- a/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/PersonneGenerate.php +++ b/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/PersonneGenerate.php @@ -18,8 +18,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * Input: a string such as "Julie Faitot et Jonathan Loppin" or * "Sonja Beaudouin, Alexandre Delabrière". Names are split on "et" and * commas. For each name, the first word is used as 'field_prenom' and the - * rest as the node title. An existing node with the same title and prenom - * is reused, otherwise it is created. Returns an array of node IDs. + * rest as 'field_nom'. The node title is generated from those two fields by + * the auto_entitylabel module on save. An existing node with the same nom and + * prenom is reused, otherwise it is created. Returns an array of node IDs. * * When 'type_de_personne' is set in the configuration, a taxonomy term with * that name is found or created (without duplicates) in the @@ -103,7 +104,7 @@ final class PersonneGenerate extends ProcessPluginBase implements ContainerFacto $query = $storage->getQuery() ->condition('type', 'personne') - ->condition('title', $nom) + ->condition('field_nom', $nom) ->accessCheck(FALSE) ->range(0, 1); if ($prenom !== '') { @@ -121,9 +122,11 @@ final class PersonneGenerate extends ProcessPluginBase implements ContainerFacto return $nid; } + // The node title is generated from field_prenom + field_nom by + // auto_entitylabel on save; it is intentionally not set here. $values = [ 'type' => 'personne', - 'title' => $nom, + 'field_nom' => $nom, 'field_prenom' => $prenom === '' ? NULL : $prenom, 'status' => 1, 'uid' => $this->configuration['author_uid'] ?? 3, diff --git a/web/modules/custom/migrate_leshed/src/Plugin/migrate/source/ProjetNode.php b/web/modules/custom/migrate_leshed/src/Plugin/migrate/source/ProjetNode.php index 8b2c400..8413161 100644 --- a/web/modules/custom/migrate_leshed/src/Plugin/migrate/source/ProjetNode.php +++ b/web/modules/custom/migrate_leshed/src/Plugin/migrate/source/ProjetNode.php @@ -4,11 +4,31 @@ declare(strict_types=1); namespace Drupal\migrate_leshed\Plugin\migrate\source; +use Drupal\Core\File\FileSystemInterface; use Drupal\migrate\Attribute\MigrateSource; -use Drupal\migrate_plus\Plugin\migrate\source\Url; +use Drupal\migrate\Plugin\MigrationInterface; use Drupal\migrate\Row; +use Drupal\migrate_plus\DataParserPluginManager; +use Drupal\migrate_plus\Plugin\migrate\source\Url; +use League\CommonMark\CommonMarkConverter; +use Symfony\Component\DependencyInjection\ContainerInterface; /** + * Source plugin for projects, enriched with body and images from the drive. + * + * On top of the Google Sheets columns, this source reads the local files + * synced from the shared Google Drive (see the `sync_leshed_drive` Makefile + * target) that live under public://drive_shared_leshed. Files are grouped in + * per-project folders named "Page{UID}" (optionally suffixed, e.g. + * "Page1_Complet"), where {UID} matches the sheet UID column: + * - "{UID}_texte_*.md": the body, written in Markdown, converted to HTML. + * - "{UID}_images|Images/": one folder of images, each "{name}.jpg" paired + * with a "{name}_legende.md" caption used as the (required) alt text. + * + * These are exposed as two extra source properties consumed by the migration: + * - body_html: the converted HTML string (or NULL). + * - images: a list of ['uri' => string, 'alt' => string] items. + * * @MigrateSource( * id = "projet_node" * ) @@ -16,18 +36,65 @@ use Drupal\migrate\Row; #[MigrateSource(id: 'projet_node')] final class ProjetNode extends Url { + /** + * Stream wrapper URI of the synced drive folder. + */ + private const DRIVE_URI = 'public://drive_shared_leshed'; + + /** + * Image file extensions to import into field_images. + */ + private const IMAGE_EXTENSIONS = ['png', 'gif', 'jpg', 'jpeg', 'webp']; + + /** + * Default destination directory when no constant is configured. + */ + private const DEFAULT_IMAGE_DESTINATION = 'public://projets/'; + + /** + * Lazily instantiated Markdown converter. + */ + private ?CommonMarkConverter $markdownConverter = NULL; + + /** + * Memoized map of UID => project folder URI, built once from the drive root. + * + * @var array|null + */ + private ?array $folderMap = NULL; + + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + MigrationInterface $migration, + DataParserPluginManager $parserPluginManager, + private readonly FileSystemInterface $fileSystem, + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $parserPluginManager); + } + + /** + * {@inheritdoc} + */ + public static function create($container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL): self { + return new self( + $configuration, + $plugin_id, + $plugin_definition, + $migration, + $container->get('plugin.manager.migrate_plus.data_parser'), + $container->get('file_system'), + ); + } + /** * {@inheritdoc} */ public function fields(): array { - // theese are source fields - return [ - 'id' => $this->t('Project ID'), - 'title' => $this->t('Project Title'), - 'date_start' => $this->t('Project date start'), - 'date_end' => $this->t('Project date end'), - 'projet_type' => $this->t('Type de projet'), - 'curator' => $this->t('Commissaria') + return parent::fields() + [ + 'body_html' => $this->t('Project body (HTML converted from Markdown)'), + 'images' => $this->t('Project images with captions'), ]; } @@ -45,16 +112,211 @@ final class ProjetNode extends Url { /** * {@inheritdoc} */ - public function prepareRow(Row $row) { - // $nid = $row->getSourceProperty('entity_id'); - // $vid = $row->getSourceProperty('revision_id'); - // $type = $row->getSourceProperty('type'); - // $language = $row->getSourceProperty('language'); - // $title = $row->getSourceProperty('title'); - // // drush_print('-- '.$nid."\t".$title."\t".$language); + public function prepareRow(Row $row): bool { + // Let the parent (and prepare-row event subscribers) decide first: no need + // to hit the filesystem for a row that will be skipped. + if (!parent::prepareRow($row)) { + return FALSE; + } + // Always define both properties so the process pipeline never references + // an undefined source key, even when no drive folder exists yet. + $row->setSourceProperty('body_html', NULL); + $row->setSourceProperty('images', []); + $uid = trim((string) $row->getSourceProperty('id')); + $folder = $uid !== '' ? $this->findProjectFolder($uid) : NULL; + if ($folder !== NULL) { + $row->setSourceProperty('body_html', $this->readBody($folder, $uid)); + $row->setSourceProperty('images', $this->readImages($folder, $uid, (string) $row->getSourceProperty('title'))); + } - return parent::prepareRow($row); + return TRUE; } + + /** + * Returns the public:// URI of the project folder, or NULL when missing. + */ + private function findProjectFolder(string $uid): ?string { + return $this->getFolderMap()[$uid] ?? NULL; + } + + /** + * Builds (once) the UID => project folder URI map from the drive root. + * + * @return array + * Map keyed by the numeric part of each "Page{UID}[_suffix]" folder. + */ + private function getFolderMap(): array { + if ($this->folderMap !== NULL) { + return $this->folderMap; + } + + $this->folderMap = []; + $base = $this->fileSystem->realpath(self::DRIVE_URI); + if ($base === FALSE || !is_dir($base)) { + return $this->folderMap; + } + // Match "Page{N}" exactly or with a suffix ("_Complet", ...). Sorting keeps + // the resolution deterministic when e.g. "Page5" and "Page5_Complet" + // coexist (the plain, longer-suffixed folder wins consistently). + $entries = scandir($base) ?: []; + sort($entries, SORT_NATURAL | SORT_FLAG_CASE); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($base . '/' . $entry)) { + continue; + } + if (preg_match('/^Page(\d+)(_.*)?$/', $entry, $matches)) { + $this->folderMap[$matches[1]] = self::DRIVE_URI . '/' . $entry; + } + } + + return $this->folderMap; + } + + /** + * Reads the Markdown body file and converts it to HTML. + */ + private function readBody(string $folderUri, string $uid): ?string { + $dir = $this->fileSystem->realpath($folderUri); + if ($dir === FALSE) { + return NULL; + } + // Prefer "{UID}_texte*.md", fall back to any Markdown file at the root. + $preferred = '/^' . preg_quote($uid, '/') . '_texte.*\.md$/i'; + $entries = scandir($dir) ?: []; + sort($entries, SORT_NATURAL | SORT_FLAG_CASE); + $markdown = NULL; + foreach ($entries as $entry) { + if (!is_file($dir . '/' . $entry) || !preg_match('/\.md$/i', $entry)) { + continue; + } + if (preg_match($preferred, $entry)) { + $markdown = $dir . '/' . $entry; + break; + } + $markdown ??= $dir . '/' . $entry; + } + if ($markdown === NULL) { + return NULL; + } + + $contents = file_get_contents($markdown); + if ($contents === FALSE || trim($contents) === '') { + return NULL; + } + + return (string) $this->getMarkdownConverter()->convert($contents); + } + + /** + * Builds the list of images (URI + alt) for the project. + * + * @return array + * The images to import. + */ + private function readImages(string $folderUri, string $uid, string $title): array { + $dir = $this->fileSystem->realpath($folderUri); + if ($dir === FALSE) { + return []; + } + // The destination is passed through each item because a sub_process row + // cannot read the parent migration's `constants`. It is namespaced per + // project (UID) so identically-named files from different projects never + // collide when file_import reuses existing files (reuse: true). + $base = $this->configuration['constants']['images_destination'] + ?? self::DEFAULT_IMAGE_DESTINATION; + $destination = rtrim($base, '/') . '/' . $uid . '/'; + // Locate the images subfolder ("{UID}_images", case-insensitive). + $imagesDirUri = NULL; + $pattern = '/^' . preg_quote($uid, '/') . '_images$/i'; + foreach (scandir($dir) ?: [] as $entry) { + if (preg_match($pattern, $entry) && is_dir($dir . '/' . $entry)) { + $imagesDirUri = $folderUri . '/' . $entry; + break; + } + } + if ($imagesDirUri === NULL) { + return []; + } + + $imagesDir = $this->fileSystem->realpath($imagesDirUri); + if ($imagesDir === FALSE) { + return []; + } + $images = []; + $files = scandir($imagesDir) ?: []; + sort($files, SORT_NATURAL | SORT_FLAG_CASE); + foreach ($files as $file) { + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + if (!in_array($extension, self::IMAGE_EXTENSIONS, TRUE)) { + continue; + } + $name = pathinfo($file, PATHINFO_FILENAME); + $legende = $this->readCaptionFile($imagesDir . '/' . $name . '_legende.md'); + // Plain text (required alt), falling back to the project title. Capped to + // the image field's alt column length (varchar 512). + $alt = $legende !== '' ? $this->toPlainText($legende) : $title; + $alt = mb_substr($alt, 0, 512); + // Rich caption (image_field_caption): Markdown converted to HTML. + $caption = $legende !== '' ? (string) $this->getMarkdownConverter()->convert($legende) : ''; + $images[] = [ + 'uri' => $imagesDirUri . '/' . $file, + 'alt' => $alt, + 'caption' => $caption, + 'destination' => $destination, + ]; + } + + return $images; + } + + /** + * Returns the raw contents of a caption (legende) file, or '' when missing. + */ + private function readCaptionFile(string $captionPath): string { + if (!is_file($captionPath)) { + return ''; + } + $contents = file_get_contents($captionPath); + if ($contents === FALSE) { + return ''; + } + // Drive exports are occasionally Windows-1252 rather than UTF-8. + if (!mb_check_encoding($contents, 'UTF-8')) { + $contents = mb_convert_encoding($contents, 'UTF-8', 'Windows-1252'); + } + + return trim($contents); + } + + /** + * Reduces Markdown/HTML text to a single trimmed plain-text line (for alt). + */ + private function toPlainText(string $text): string { + $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // Drop Markdown emphasis/heading markers left after tag stripping. + $text = preg_replace('/[*_#`>]+/u', '', $text) ?? $text; + + return trim((string) preg_replace('/\s+/u', ' ', $text)); + } + + /** + * Returns the shared Markdown converter. + * + * Raw HTML embedded in the source Markdown (alignment
,
, ...) is + * stripped: only the Markdown itself is converted to HTML, keeping the body + * clean and safe for the wysiwyg format. + */ + private function getMarkdownConverter(): CommonMarkConverter { + if ($this->markdownConverter === NULL) { + $this->markdownConverter = new CommonMarkConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => FALSE, + ]); + } + + return $this->markdownConverter; + } + } diff --git a/web/themes/custom/leshed/templates/node--personne--teaser.html.twig b/web/themes/custom/leshed/templates/node--personne--teaser.html.twig new file mode 100644 index 0000000..e69de29 diff --git a/web/themes/custom/leshed/templates/node.html.twig b/web/themes/custom/leshed/templates/node.html.twig new file mode 100644 index 0000000..1e0ba9e --- /dev/null +++ b/web/themes/custom/leshed/templates/node.html.twig @@ -0,0 +1,89 @@ +{# +/** + * @file + * Default theme implementation to display a node. + * + * Available variables: + * - node: The node entity with limited access to object properties and methods. + * Only method names starting with "get", "has", or "is" and a few common + * methods such as "id", "label", and "bundle" are available. For example: + * - node.getCreatedTime() will return the node creation timestamp. + * - node.hasField('field_example') returns TRUE if the node bundle includes + * field_example. (This does not indicate the presence of a value in this + * field.) + * - node.isPublished() will return whether the node is published or not. + * Calling other methods, such as node.delete(), will result in an exception. + * See \Drupal\node\Entity\Node for a full list of public properties and + * methods for the node object. + * - label: (optional) The title of the node. + * - content: All node items. Use {{ content }} to print them all, + * or print a subset such as {{ content.field_example }}. Use + * {{ content|without('field_example') }} to temporarily suppress the printing + * of a given child element. + * - author_picture: The node author user entity, rendered using the "compact" + * view mode. + * - metadata: Metadata for this node. + * - date: (optional) Themed creation date field. + * - author_name: (optional) Themed author name field. + * - url: Direct URL of the current node. + * - display_submitted: Whether submission information should be displayed. + * - attributes: HTML attributes for the containing element. + * The attributes.class element may contain one or more of the following + * classes: + * - node: The current template type (also known as a "theming hook"). + * - node--type-[type]: The current node type. For example, if the node is an + * "Article" it would result in "node--type-article". Note that the machine + * name will often be in a short form of the human readable label. + * - node--view-mode-[view_mode]: The View Mode of the node; for example, a + * teaser would result in: "node--view-mode-teaser", and + * full: "node--view-mode-full". + * The following are controlled through the node publishing options. + * - node--promoted: Appears on nodes promoted to the front page. + * - node--sticky: Appears on nodes ordered above other non-sticky nodes in + * teaser listings. + * - node--unpublished: Appears on unpublished nodes visible only to site + * admins. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - content_attributes: Same as attributes, except applied to the main + * content tag that appears in the template. + * - author_attributes: Same as attributes, except applied to the author of + * the node tag that appears in the template. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * - view_mode: View mode; for example, "teaser" or "full". + * - teaser: Flag for the teaser state. Will be true if view_mode is 'teaser'. + * - page: Flag for the full page state. Will be true if view_mode is 'full'. + * + * @see template_preprocess_node() + * + * @ingroup themeable + */ +#} + + + {{ title_prefix }} + {% if label and not page %} + + {{ label }} + + {% endif %} + {{ title_suffix }} + + {% if display_submitted %} +
+ {{ author_picture }} + + {% trans %}Submitted by {{ author_name }} on {{ date }}{% endtrans %} + {{ metadata }} +
+ + {% endif %} + + + {{ content }} + + +