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 2d0e595..e3971d3 100644 --- a/config/sync/core.entity_form_display.node.projet.default.yml +++ b/config/sync/core.entity_form_display.node.projet.default.yml @@ -6,6 +6,7 @@ dependencies: - field.field.node.projet.body - field.field.node.projet.field_artiste_s_invite_s - field.field.node.projet.field_dates + - field.field.node.projet.field_fichiers - field.field.node.projet.field_images - field.field.node.projet.field_invite_accompagne_curate_p - field.field.node.projet.field_partenaire_s @@ -18,6 +19,7 @@ dependencies: - autocomplete_deluxe - datetime_range - field_group + - file - image - path - text @@ -101,6 +103,7 @@ third_party_settings: group_media: children: - field_images + - field_fichiers label: Media region: content parent_name: group_tabs @@ -161,6 +164,13 @@ content: region: content settings: { } third_party_settings: { } + field_fichiers: + type: file_generic + weight: 8 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } field_images: type: image_image weight: 7 diff --git a/config/sync/core.entity_view_display.node.projet.default.yml b/config/sync/core.entity_view_display.node.projet.default.yml index 90ab17a..0fffa78 100644 --- a/config/sync/core.entity_view_display.node.projet.default.yml +++ b/config/sync/core.entity_view_display.node.projet.default.yml @@ -6,6 +6,7 @@ dependencies: - field.field.node.projet.body - field.field.node.projet.field_artiste_s_invite_s - field.field.node.projet.field_dates + - field.field.node.projet.field_fichiers - field.field.node.projet.field_images - field.field.node.projet.field_invite_accompagne_curate_p - field.field.node.projet.field_partenaire_s @@ -15,6 +16,7 @@ dependencies: - node.type.projet module: - datetime_range + - file - image - options - text @@ -50,6 +52,14 @@ content: third_party_settings: { } weight: 102 region: content + field_fichiers: + type: file_default + label: above + settings: + use_description_as_link_text: true + third_party_settings: { } + weight: 110 + region: content field_images: type: image label: above 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 e975166..4ddb3bb 100644 --- a/config/sync/core.entity_view_display.node.projet.teaser.yml +++ b/config/sync/core.entity_view_display.node.projet.teaser.yml @@ -7,6 +7,7 @@ dependencies: - field.field.node.projet.body - field.field.node.projet.field_artiste_s_invite_s - field.field.node.projet.field_dates + - field.field.node.projet.field_fichiers - field.field.node.projet.field_images - field.field.node.projet.field_invite_accompagne_curate_p - field.field.node.projet.field_partenaire_s @@ -56,6 +57,7 @@ content: region: content hidden: field_dates: true + field_fichiers: true field_invite_accompagne_curate_p: true field_partenaire_s: true field_portage: true diff --git a/config/sync/field.field.node.projet.field_fichiers.yml b/config/sync/field.field.node.projet.field_fichiers.yml new file mode 100644 index 0000000..0005e21 --- /dev/null +++ b/config/sync/field.field.node.projet.field_fichiers.yml @@ -0,0 +1,27 @@ +uuid: 01cf5142-1617-4b0a-afbe-46bed19f3a75 +langcode: fr +status: true +dependencies: + config: + - field.storage.node.field_fichiers + - node.type.projet + module: + - file +id: node.projet.field_fichiers +field_name: field_fichiers +entity_type: node +bundle: projet +label: Fichiers +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'pdf doc docx odt zip txt' + max_filesize: '' + description_field: true +field_type: file diff --git a/config/sync/field.storage.node.field_fichiers.yml b/config/sync/field.storage.node.field_fichiers.yml new file mode 100644 index 0000000..d9d6d14 --- /dev/null +++ b/config/sync/field.storage.node.field_fichiers.yml @@ -0,0 +1,23 @@ +uuid: 9a9ebb54-2e2f-46bd-9586-60ebedfc95d8 +langcode: fr +status: true +dependencies: + module: + - file + - node +id: node.field_fichiers +field_name: field_fichiers +entity_type: node +type: file +settings: + target_type: file + display_field: true + display_default: false + uri_scheme: public +module: file +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 f7d1414..5cdd0b8 100644 --- a/config/sync/migrate_plus.migration.projet_node.yml +++ b/config/sync/migrate_plus.migration.projet_node.yml @@ -84,6 +84,17 @@ process: image_field_caption/format: plugin: default_value default_value: wysiwyg + field_fichiers: + plugin: sub_process + source: pdfs + process: + target_id: + plugin: file_import + source: uri + destination: destination + reuse: true + skip_on_missing_source: true + description: description field_dates/value: plugin: leshed_date source: date_start diff --git a/config/sync/system.site.yml b/config/sync/system.site.yml index f1789d6..d3f269a 100644 --- a/config/sync/system.site.yml +++ b/config/sync/system.site.yml @@ -4,7 +4,7 @@ langcode: fr uuid: e53d1b41-902a-4030-a476-ad2d5f3e5070 name: 'Le Shed' mail: dev@figureslibres.io -slogan: '' +slogan: "Centre d'art en Normandie" page: 403: '' 404: '' 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 51f68d6..efea347 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 @@ -98,6 +98,17 @@ process: image_field_caption/format: plugin: default_value default_value: wysiwyg + field_fichiers: + plugin: sub_process + source: pdfs + process: + target_id: + plugin: file_import + source: uri + destination: destination + reuse: true + skip_on_missing_source: true + description: description field_dates/value: plugin: leshed_date source: date_start diff --git a/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/FileImport.php b/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/FileImport.php new file mode 100644 index 0000000..4d4396e --- /dev/null +++ b/web/modules/custom/migrate_leshed/src/Plugin/migrate/process/FileImport.php @@ -0,0 +1,153 @@ + + */ + private array $fidCache = []; + + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + private readonly FileSystemInterface $fileSystem, + private readonly EntityTypeManagerInterface $entityTypeManager, + ) { + $configuration += [ + '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('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; + } + + if (!is_file($source) && !is_file($this->fileSystem->realpath($source) ?: $source)) { + return $this->skipFile($migrate_executable, "Source file '$source' is missing."); + } + + $directory = $this->getPropertyValue($this->configuration['destination'], $row); + if (!is_string($directory) || $directory === '') { + return $this->skipFile($migrate_executable, "Missing 'destination' for file '$source'."); + } + $directory = rtrim($directory, '/') . '/'; + $destination = $directory . $this->fileSystem->basename($source); + + // Reuse an already imported file to keep the migration idempotent. + 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(); + } + } + + try { + if (!$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) { + return $this->skipFile($migrate_executable, "Cannot prepare directory '$directory'."); + } + $this->fileSystem->copy($source, $destination, FileExists::Replace); + + $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->skipFile($migrate_executable, "Import of file '$source' failed: " . $e->getMessage()); + } + + return $this->fidCache[$destination] = (int) $file->id(); + } + + /** + * Logs a message and skips the current file item. + * + * @throws \Drupal\migrate\MigrateSkipRowException + */ + private function skipFile(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/source/ProjetNode.php b/web/modules/custom/migrate_leshed/src/Plugin/migrate/source/ProjetNode.php index 6df6521..788018d 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,7 +4,9 @@ declare(strict_types=1); namespace Drupal\migrate_leshed\Plugin\migrate\source; +use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Utility\Token; use Drupal\migrate\Attribute\MigrateSource; use Drupal\migrate\Plugin\MigrationInterface; use Drupal\migrate\Row; @@ -46,6 +48,11 @@ final class ProjetNode extends Url { */ private const IMAGE_EXTENSIONS = ['png', 'gif', 'jpg', 'jpeg', 'webp']; + /** + * File extensions to import into field_fichiers. + */ + private const FILE_EXTENSIONS = ['pdf']; + /** * Default destination directory when no constant is configured. */ @@ -63,6 +70,11 @@ final class ProjetNode extends Url { */ private ?array $folderMap = NULL; + /** + * Memoized destination directory for field_fichiers (from field settings). + */ + private ?string $filesDestination = NULL; + public function __construct( array $configuration, $plugin_id, @@ -70,6 +82,8 @@ final class ProjetNode extends Url { MigrationInterface $migration, DataParserPluginManager $parserPluginManager, private readonly FileSystemInterface $fileSystem, + private readonly EntityFieldManagerInterface $entityFieldManager, + private readonly Token $token, ) { parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $parserPluginManager); } @@ -85,6 +99,8 @@ final class ProjetNode extends Url { $migration, $container->get('plugin.manager.migrate_plus.data_parser'), $container->get('file_system'), + $container->get('entity_field.manager'), + $container->get('token'), ); } @@ -95,6 +111,7 @@ final class ProjetNode extends Url { return parent::fields() + [ 'body_html' => $this->t('Project body (HTML converted from Markdown)'), 'images' => $this->t('Project images with captions'), + 'pdfs' => $this->t('Project PDF files'), ]; } @@ -123,12 +140,14 @@ final class ProjetNode extends Url { // an undefined source key, even when no drive folder exists yet. $row->setSourceProperty('body_html', NULL); $row->setSourceProperty('images', []); + $row->setSourceProperty('pdfs', []); $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'))); + $row->setSourceProperty('pdfs', $this->readPdfs($folder, $uid)); } return TRUE; @@ -188,8 +207,7 @@ final class ProjetNode extends Url { sort($entries, SORT_NATURAL | SORT_FLAG_CASE); $markdown = NULL; foreach ($entries as $entry) { - // Ignore ".back.md" backups of the pre-clean source text. - if (!is_file($dir . '/' . $entry) || !preg_match('/\.md$/i', $entry) || preg_match('/\.back\.md$/i', $entry)) { + if (!is_file($dir . '/' . $entry) || !preg_match('/\.md$/i', $entry)) { continue; } if (preg_match($preferred, $entry)) { @@ -206,10 +224,44 @@ final class ProjetNode extends Url { if ($contents === FALSE || trim($contents) === '') { return NULL; } + $contents = $this->cleanBodyMarkdown($contents); + if ($contents === '') { + return NULL; + } return (string) $this->getMarkdownConverter()->convert($contents); } + /** + * Cleans the raw source Markdown before conversion. + * + * Removes redundant content already present elsewhere: raw HTML wrappers, + * invisible characters, and the leading title block (H1 title + H2/H3 + * artists/subtitle, redundant with the sheet). Headings located *inside* the + * body (e.g. artist/curator bios) are preserved. Idempotent: running it on an + * already-cleaned text has no effect. + */ + private function cleanBodyMarkdown(string $md): string { + // 1. Strip HTML tags (