diff --git a/redirect_bulk.links.action.yml b/redirect_bulk.links.action.yml index 3ba8e904f97eb8710a8adb229c5d311e38a05494..2cf8a6ffdba167854c961beca1e1ad750c67ff7d 100644 --- a/redirect_bulk.links.action.yml +++ b/redirect_bulk.links.action.yml @@ -3,3 +3,10 @@ redirect_bulk.add_bulk_redirect: title: 'Add Bulk redirects' appears_on: - 'redirect.list' + +redirect_bulk.add_csv_redirect: + route_name: redirect_bulk.csv_form + title: 'Import CSV' + appears_on: + - 'redirect.list' + \ No newline at end of file diff --git a/redirect_bulk.routing.yml b/redirect_bulk.routing.yml index c17b9707930a6ca217edbc2d0dc5f137077c16e9..b2221b38e2551fbaccdb04d6eb2dac6b6fcbc34f 100644 --- a/redirect_bulk.routing.yml +++ b/redirect_bulk.routing.yml @@ -13,3 +13,11 @@ redirect_bulk.node_autocomplete: _title: 'Node Autocomplete' requirements: _permission: 'access content' + +redirect_bulk.csv_form: + path: '/admin/config/search/redirect/add-csv' + defaults: + _form: '\Drupal\redirect_bulk\Form\CsvForm' + _title: 'Import csv' + requirements: + _permission: 'administer bulk redirects' diff --git a/src/Form/CsvForm.php b/src/Form/CsvForm.php new file mode 100644 index 0000000000000000000000000000000000000000..c14e7f17cc1b0eff3a0401dabc10f44eb6bfcc57 --- /dev/null +++ b/src/Form/CsvForm.php @@ -0,0 +1,222 @@ +<?php + +namespace Drupal\redirect_bulk\Form; + +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\redirect\Entity\Redirect; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form to import csv. + */ +class CsvForm extends FormBase { + + /** + * Config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * Messenger service. + * + * @var \Drupal\Core\Messenger\MessengerInterface + */ + protected $messenger; + + /** + * Constructs a new CsvForm. + */ + public function __construct(ConfigFactoryInterface $config_factory, MessengerInterface $messenger) { + $this->configFactory = $config_factory; + $this->messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('messenger') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'redirect_bulk_import_csv'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['import'] = [ + '#type' => 'file', + '#title' => $this->t('Import CSV'), + '#description' => $this->t('Upload a CSV file with redirects. Format: from, to, code, langcode (langcode is optional).'), + '#required' => TRUE, + ]; + + $form['actions']['#type'] = 'actions'; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Import'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $validators = ['file_validate_extensions' => ['csv']]; + $file = file_save_upload('import', $validators, FALSE, 0, FileSystemInterface::EXISTS_REPLACE); + + if ($file) { + $file->setPermanent(); + $file->save(); + $file_path = $file->getFileUri(); + $content = file_get_contents($file_path); + $redirects = $this->parseCsv($content); + + if (empty($redirects)) { + $this->messenger->addError($this->t('The uploaded CSV file is empty or invalid.')); + return; + } + + $success_count = 0; + $error_count = 0; + + foreach ($redirects as $redirect) { + if ($this->validateRedirect($redirect)) { + $to = $this->clearUrl($redirect['to']); + if (!UrlHelper::isExternal($to)) { + $to = 'internal:/' . ltrim($to, '/'); + } + + $source = UrlHelper::parse($redirect['from']); + $path = $this->clearUrl($source['path']); + $query = $source['query'] ?? []; + $status_code = $redirect['code']; + $langcode = $redirect['langcode']; + $default_code = $this->configFactory->get('redirect.settings')->get('default_status_code') ?? 301; + + Redirect::create([ + 'redirect_source' => [ + 'path' => $path, + 'query' => $query, + ], + 'redirect_redirect' => [ + 'uri' => $to, + 'title' => '', + ], + 'status_code' => (!empty($status_code) && is_numeric($status_code)) ? (int) trim($status_code) : $default_code, + 'language' => !empty($langcode) ? $langcode : 'und', + ])->save(); + + $success_count++; + } + else { + $error_count++; + } + } + if ($success_count > 0) { + $this->messenger->addStatus($this->t('@count redirects successfully imported.', ['@count' => $success_count])); + } + if ($error_count > 0) { + $this->messenger->addWarning($this->t('@count redirects could not be processed. Please ensure all entries follow the correct format.', ['@count' => $error_count])); + } + } + else { + $this->messenger->addError($this->t('Failed to upload the file.')); + } + } + + /** + * Parses CSV content into an array of redirects. + */ + private function parseCsv(string $content): array { + $lines = array_filter(explode("\n", $content)); + $redirects = []; + + foreach ($lines as $line) { + $data = str_getcsv($line); + if (count($data) >= 2) { + + $redirects[] = [ + 'from' => isset($data[0]) ? trim($data[0]) : '', + 'to' => isset($data[1]) ? trim($data[1]) : '', + 'code' => isset($data[2]) ? trim($data[2]) : '', + 'langcode' => isset($data[3]) ? trim($data[3]) : '', + ]; + } + } + return $redirects; + } + + /** + * Validates a single redirect entry. + */ + private function validateRedirect(array $redirect): bool { + $from = $this->clearUrl($redirect['from']); + $to = $this->clearUrl($redirect['to']); + $langcode = !empty($redirect['langcode']) ? $redirect['langcode'] : 'und'; + $source = UrlHelper::parse($redirect['from']); + $path = $this->clearUrl($source['path']); + $query = $source['query']; + $available_languages = \Drupal::languageManager()->getLanguages(); + + $hash = Redirect::generateHash($path, $query, $langcode); + $existing_redirects = \Drupal::entityTypeManager() + ->getStorage('redirect') + ->loadByProperties(['hash' => $hash]); + + if (!empty($existing_redirects)) { + $existing_redirect = reset($existing_redirects); + $this->messenger->addError($this->t('The source path %source is already being redirected. Edit the existing redirect <a href="@edit-url">here</a>.', [ + '%source' => $path, + '@edit-url' => $existing_redirect->toUrl('edit-form')->toString(), + ])); + return FALSE; + } + + if (empty($from) || empty($to)) { + $this->messenger->addError($this->t('Source and destination paths cannot be empty.')); + return FALSE; + } + + if ($from === $to) { + $this->messenger->addError($this->t('Source and destination paths cannot be the same.')); + return FALSE; + } + + if (!empty($redirect['code']) && !in_array((int) $redirect['code'], range(300, 307), TRUE)) { + $this->messenger->addError($this->t('Invalid redirect code: @code', ['@code' => $redirect['code']])); + return FALSE; + } + + if (!empty($redirect['langcode']) && !array_key_exists($langcode, $available_languages)) { + $this->messenger->addError($this->t('Invalid language code: @code', ['@code' => $redirect['langcode']])); + return FALSE; + } + return TRUE; + } + + /** + * Clears and sanitizes the URL. + */ + private function clearUrl(string $url): string { + return ltrim(trim($url), '/'); + } + +}