migration projets: body markdown, images HD, légendes, personnes prénom/nom

- ProjetNode: body Markdown->HTML (strip HTML brut), images + légendes lues
  depuis public://drive_shared_leshed/Page{UID}
- ImageResizeImport: nouveau process plugin, redimensionne les images en web HD
  (max 1920px) et crée le fichier managé (idempotent)
- field_images: caption via image_field_caption, alt depuis les légendes
- PersonneGenerate: nom dans field_nom (titre auto-généré via auto_entitylabel),
  dédup sur field_nom + field_prenom
- title: skip_on_empty (ligne ignorée si "Nom du projet" vide)
- CER: relation curateur sur field_projets_curates (distinct de field_projet_s_lie_s)
- deps: + league/commonmark, image_field_caption, auto_entitylabel ; - migrate_file
This commit is contained in:
2026-07-26 16:26:18 +02:00
parent 829653a9e9
commit a2cb5f9dcf
28 changed files with 1600 additions and 142 deletions
@@ -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/<SHEET>/values/<TAB>?key=<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
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Drupal\migrate_leshed\Plugin\migrate\process;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Copies a local image, downscaling it to a web-friendly size, as a File.
*
* The source value is a readable file path/URI. The image is scaled down
* (aspect ratio preserved, never upscaled) to fit within max_width x
* max_height, saved into the destination directory and registered as a
* managed file. Returns the file id.
*
* Configuration:
* - destination: (required) destination directory URI, e.g. 'public://projets/1/'.
* - max_width: maximum width in pixels (default 1920).
* - max_height: maximum height in pixels (default 1920).
* - uid: owner uid of the created file (default 0).
* - reuse: reuse an existing managed file at the destination URI (default TRUE).
* - skip_on_missing_source: skip the value when the source is missing
* (default TRUE) instead of failing.
*
* @code
* field_images/target_id:
* plugin: image_resize_import
* source: uri
* destination: destination
* max_width: 1920
* max_height: 1920
* @endcode
*/
#[MigrateProcess(id: 'image_resize_import')]
final class ImageResizeImport extends ProcessPluginBase implements ContainerFactoryPluginInterface {
/**
* Per-run cache of already resolved destination URIs to file ids.
*
* @var array<string, int>
*/
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;
}
}
@@ -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,
@@ -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<string, string>|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<string, string>
* 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<int, array{uri: string, alt: string, caption: string, destination: string}>
* 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 <div>, <br>, ...) 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;
}
}
@@ -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
*/
#}
<article{{ attributes }}>
{{ title_prefix }}
{% if label and not page %}
<h2{{ title_attributes }}>
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
</h2>
{% endif %}
{{ title_suffix }}
{% if display_submitted %}
<footer>
{{ author_picture }}
<div{{ author_attributes }}>
{% trans %}Submitted by {{ author_name }} on {{ date }}{% endtrans %}
{{ metadata }}
</div>
</footer>
{% endif %}
<div{{ content_attributes }}>
{{ content }}
</div>
</article>