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 (
,
,
, ...). + $md = (string) preg_replace('/<[^>]+>/', '', $md); + // 2. Strip zero-width/BOM characters and turn NBSP into a normal space. + $md = (string) preg_replace('/[\x{200B}\x{200E}\x{200F}\x{FEFF}]/u', '', $md); + $md = (string) preg_replace('/\x{00A0}/u', ' ', $md); + // 3. Drop only the leading heading block: skip leading blank and heading + // lines until the first body line. + $lines = preg_split('/\R/u', $md) ?: []; + $i = 0; + $count = count($lines); + while ($i < $count && (trim($lines[$i]) === '' || preg_match('/^\s*#{1,6}\s/u', $lines[$i]))) { + $i++; + } + $md = implode("\n", array_slice($lines, $i)); + // 4. Collapse blank lines and trim. + $md = (string) preg_replace('/\n{3,}/', "\n\n", $md); + + return trim($md); + } + /** * Builds the list of images (URI + alt) for the project. * @@ -272,6 +324,90 @@ final class ProjetNode extends Url { return $images; } + /** + * Builds the list of PDF files for the project. + * + * Collects PDFs both at the project folder root and inside the "{UID}_pdf" + * subfolder. Each item carries the destination directory (from the + * field_fichiers settings) and a human-readable description. + * + * @return array + * The files to import. + */ + private function readPdfs(string $folderUri, string $uid): array { + $dir = $this->fileSystem->realpath($folderUri); + if ($dir === FALSE) { + return []; + } + + // Directories to scan: the project folder root and its "{UID}_pdf" subfolder. + $sources = [$folderUri]; + $pattern = '/^' . preg_quote($uid, '/') . '_pdf$/i'; + foreach (scandir($dir) ?: [] as $entry) { + if (preg_match($pattern, $entry) && is_dir($dir . '/' . $entry)) { + $sources[] = $folderUri . '/' . $entry; + } + } + + $destination = $this->filesDestination(); + $pdfs = []; + foreach ($sources as $sourceUri) { + $sourceDir = $this->fileSystem->realpath($sourceUri); + if ($sourceDir === FALSE) { + continue; + } + $files = scandir($sourceDir) ?: []; + sort($files, SORT_NATURAL | SORT_FLAG_CASE); + foreach ($files as $file) { + if (!is_file($sourceDir . '/' . $file)) { + continue; + } + if (!in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), self::FILE_EXTENSIONS, TRUE)) { + continue; + } + $pdfs[] = [ + 'uri' => $sourceUri . '/' . $file, + 'destination' => $destination, + 'description' => $this->humanizeFilename($file), + ]; + } + } + + return $pdfs; + } + + /** + * Resolves (once) the field_fichiers destination directory from its settings. + */ + private function filesDestination(): string { + if ($this->filesDestination !== NULL) { + return $this->filesDestination; + } + $scheme = 'public'; + $directory = ''; + $definitions = $this->entityFieldManager->getFieldDefinitions('node', 'projet'); + $field = $definitions['field_fichiers'] ?? NULL; + if ($field !== NULL) { + $scheme = $field->getFieldStorageDefinition()->getSetting('uri_scheme') ?: 'public'; + $directory = (string) $this->token->replace((string) $field->getSetting('file_directory')); + } + + return $this->filesDestination = $scheme . '://' . trim($directory, '/') . '/'; + } + + /** + * Turns a file name into a readable label (drops UID prefix and extension). + */ + private function humanizeFilename(string $file): string { + $name = pathinfo($file, PATHINFO_FILENAME); + // Drop a leading "{UID}_" numeric prefix. + $name = preg_replace('/^\d+[_-]/', '', $name) ?? $name; + // Separators to spaces, collapse, trim. + $name = (string) preg_replace('/[_\-]+/u', ' ', $name); + + return trim((string) preg_replace('/\s+/u', ' ', $name)); + } + /** * Returns the raw contents of a caption (legende) file, or '' when missing. */