diff --git a/cdesai_migrate.links.menu.yml b/cdesai_migrate.links.menu.yml
new file mode 100644
index 0000000..4825bc7
--- /dev/null
+++ b/cdesai_migrate.links.menu.yml
@@ -0,0 +1,6 @@
+# Settings form
+cdesai_migrate.settings:
+ title: 'Demo Migrations'
+ description: 'Configure Demo migration settings.'
+ route_name: cdesai_migrate.settings
+ parent: system.admin_config_development
diff --git a/cdesai_migrate.routing.yml b/cdesai_migrate.routing.yml
new file mode 100644
index 0000000..228206e
--- /dev/null
+++ b/cdesai_migrate.routing.yml
@@ -0,0 +1,7 @@
+cdesai_migrate.settings:
+ path: '/admin/config/development/cdesai/migrate'
+ defaults:
+ _form: '\Drupal\cdesai_migrate\Form\CDesaiMigrateSettings'
+ _title: 'Demo Migrations'
+ requirements:
+ _permission: 'administer demo migrate'
diff --git a/src/DemoMigrateConstantsInterface.php b/src/DemoMigrateConstantsInterface.php
new file mode 100644
index 0000000..bc53cf7
--- /dev/null
+++ b/src/DemoMigrateConstantsInterface.php
@@ -0,0 +1,60 @@
+ ['csv'],
+ 'archive' => ['zip'],
+ 'text' => ['txt'],
+ ];
+
+ const SOURCES = [
+ 'cdesai_migrate_basic_pages_source' => [
+ 'label' => 'Basic Pages Source',
+ 'directory' => 'private://migration/sources',
+ 'description' => 'File must be a CSV file.',
+ 'source_file_type' => 'spreadsheet',
+ 'required_columns' => [
+ 'page',
+ 'skip_migration',
+ 'scrapped',
+ 'category',
+ 'title',
+ 'legacy_url',
+ ],
+ ],
+ 'cdesai_migrate_profile_pages_source' => [
+ 'label' => 'Profile Pages Source',
+ 'directory' => 'private://migration/sources',
+ 'description' => 'File must be a CSV file.',
+ 'source_file_type' => 'spreadsheet',
+ 'required_columns' => [
+ 'page',
+ 'skip_migration',
+ 'scrapped',
+ 'position',
+ 'title',
+ 'legacy_url',
+ 'year_birth',
+ 'year_death',
+ 'years_in_office',
+ 'agency',
+ 'image_url',
+ ],
+ ],
+ 'cdesai_migrate_paragraphs_source' => [
+ 'label' => 'Paragraphs Source',
+ 'directory' => 'private://migration/sources',
+ 'description' => 'File must be a ZIP archive containing one or more .txt files.',
+ 'source_file_type' => 'archive',
+ 'archive_content_file_types' => 'text',
+ ],
+ ];
+
+}
diff --git a/src/Form/CDesaiMigrateSettings.php b/src/Form/CDesaiMigrateSettings.php
new file mode 100644
index 0000000..20caa16
--- /dev/null
+++ b/src/Form/CDesaiMigrateSettings.php
@@ -0,0 +1,333 @@
+moduleHandler = $module_handler;
+ $this->fileSystem = $file_system;
+ $this->archiveManager = $archive_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('module_handler'),
+ $container->get('file_system'),
+ $container->get('plugin.manager.archiver')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames(): array {
+ return [
+ DemoMigrateConstantsInterface::CONFIG_NAME,
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return self::FORM_ID;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+ $configs = $this->config(DemoMigrateConstantsInterface::CONFIG_NAME);
+
+ foreach (DemoMigrateConstantsInterface::SOURCES as $sourceKey => $source) {
+
+ $currentPath = $configs->get($sourceKey . '_path');
+ $text = $this->t('Upload a file');
+ if ($currentPath && $this->validatePath($currentPath)) {
+ $text = $currentPath;
+ }
+
+ $form[$sourceKey . '_current_path'] = [
+ '#type' => 'markup',
+ '#markup' =>
+ "Current {$source['label']} file: " . $text,
+ ];
+
+ $form[$sourceKey . '_upload'] = [
+ '#type' => 'file',
+ '#title' => $this->t('Upload %title file', ['%title' => $source['label']]),
+ '#description' => $source['description'],
+ '#upload_validators' => [
+ 'file_validate_extensions' => DemoMigrateConstantsInterface::FILE_TYPES[$source['source_file_type']],
+ ],
+ '#suffix' => '
',
+ ];
+ }
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ if ($this->moduleHandler->moduleExists('file')) {
+ foreach (DemoMigrateConstantsInterface::SOURCES as $sourceKey => $source) {
+ // Validate file only if a new file was uploaded.
+ $upload_key = $sourceKey . '_upload';
+ $file = _file_save_upload_from_form($form[$upload_key], $form_state, 0, FileSystemInterface::EXISTS_REPLACE);
+ if ($file) {
+ if ($source['source_file_type'] === 'archive') {
+ $contentFileExts = DemoMigrateConstantsInterface::FILE_TYPES[$source['archive_content_file_types']];
+ $this->validateArchiveFile($upload_key, $contentFileExts, $file, $form_state);
+ }
+ else {
+ $this->validateCsvFile($upload_key, $source['required_columns'], $file, $form_state);
+ }
+ }
+
+ }
+
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+
+ $configs = $this->config(DemoMigrateConstantsInterface::CONFIG_NAME);
+
+ // Exclude unnecessary elements before saving.
+ $form_state->cleanValues();
+ $values = $form_state->getValues();
+
+ foreach (DemoMigrateConstantsInterface::SOURCES as $sourceKey => $source) {
+ // Check for a newly uploaded file.
+ $upload_key = $sourceKey . '_upload';
+ $path_key = $sourceKey . '_path';
+
+ try {
+ if (isset($values[$upload_key]) && $values[$upload_key] instanceof File) {
+ $this->fileSystem->mkdir($source['directory'], NULL, TRUE);
+ $filename = $this->fileSystem->copy($values[$upload_key]->getFileUri(), $source['directory'], FileSystemInterface::EXISTS_RENAME);
+ $configs->set($path_key, $filename);
+ }
+ }
+ catch (FileException $e) {
+ // Ignore.
+ $this->logger('Demo Migration')->error($e->getMessage());
+ $this->messenger()->addError($e->getMessage());
+ }
+
+ }
+
+ $configs->save();
+ }
+
+ /**
+ * Helper function for the cdesai_migrate_settings form.
+ *
+ * Attempts to validate normal system paths, paths relative to the public
+ * files directory, or stream wrapper URIs. If the given path is any of the
+ * above, returns a valid path or URI that the theme system can display.
+ *
+ * @param string $path
+ * A path relative to the Drupal root or to the public files directory, or
+ * a stream wrapper URI.
+ *
+ * @return false|string
+ * A valid path that can be displayed through the theme system, or FALSE if
+ * the path could not be validated.
+ */
+ protected function validatePath(string $path) {
+ // Absolute local file paths are invalid.
+ if ($this->fileSystem->realpath($path) == $path) {
+ return FALSE;
+ }
+
+ // A path relative to the Drupal root or a fully qualified URI is valid.
+ if (is_file($path)) {
+ return $path;
+ }
+
+ // Prepend 'private://' for relative file paths within private filesystem.
+ if (StreamWrapperManager::getScheme($path) === FALSE) {
+ $path = 'private://' . $path;
+
+ if (is_file($path)) {
+ return $path;
+ }
+ }
+
+ return FALSE;
+ }
+
+ /**
+ * Validate the archive file.
+ *
+ * @param string $key
+ * Form field key.
+ * @param array $allowed_file_extensions
+ * File extensions allowed in the archive.
+ * @param \Drupal\file\FileInterface $file
+ * The file being uploaded.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ private function validateArchiveFile(string $key, array $allowed_file_extensions, FileInterface $file, FormStateInterface $form_state) {
+ $filepath = $this->fileSystem->realpath($file->getFileUri());
+ /** @var \Drupal\Core\Archiver\ArchiverInterface $archiver */
+ $archiver = $this->archiveManager->getInstance([
+ 'filepath' => $filepath,
+ ]);
+
+ if ($archiver && !empty($archiver->listContents())) {
+ $otherExt = FALSE;
+ foreach ($archiver->listContents() as $fileInArchive) {
+ $ext = pathinfo($fileInArchive, PATHINFO_EXTENSION);
+ if (!in_array($ext, $allowed_file_extensions)) {
+ $otherExt = TRUE;
+ break;
+ }
+ }
+
+ if ($otherExt) {
+ $form_state->setErrorByName($key, $this->t('Archive contains one or more files with an invalid extension. Allowed extensions are: %ext', [
+ '%ext' => implode(', ', $allowed_file_extensions),
+ ]));
+ }
+ else {
+ // Put the temporary file in form_values so we can save it
+ // on submit.
+ $form_state->setValue($key, $file);
+ }
+ }
+ else {
+ $form_state->setErrorByName($key, $this->t('Not a valid archive file.'));
+ }
+ }
+
+ /**
+ * Validate the CSV file.
+ *
+ * @param string $key
+ * Form field key.
+ * @param array $required_columns
+ * An array column keys that should be in the CSV.
+ * @param \Drupal\file\FileInterface $file
+ * The file being uploaded.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ private function validateCsvFile(string $key, array $required_columns, FileInterface $file, FormStateInterface $form_state) {
+ $readFile = FALSE;
+
+ if (($handle = fopen($file->getFileUri(), 'r')) !== FALSE) {
+ $row = fgetcsv($handle, 1000);
+ if ($row !== FALSE) {
+ $readFile = TRUE;
+
+ // Sanitize keys in the header.
+ $header = array_map([__CLASS__, 'sanitizeKey'], $row);
+ // Check for required keys.
+ if ($required_columns) {
+ $missingRequiredKeys = array_diff($required_columns, $header);
+ if (!empty($missingRequiredKeys)) {
+ $form_state->setErrorByName($key, $this->t('CSV file is missing required columns: %columns', [
+ '%columns' => implode(', ', $missingRequiredKeys),
+ ]));
+ }
+ else {
+ // Put the temporary file in form_values so we can save it
+ // on submit.
+ $form_state->setValue($key, $file);
+ }
+ }
+ }
+
+ fclose($handle);
+ }
+
+ if (!$readFile) {
+ $form_state->setErrorByName($key, $this->t('Unable to read from the file.'));
+ }
+
+ }
+
+ /**
+ * Remove whitespaces form a string and converts it to lowercase.
+ *
+ * @param string $key
+ * A string to sanitize.
+ *
+ * @return string
+ * A sanitized string.
+ */
+ public static function sanitizeKey(string $key): string {
+ $key = trim($key);
+ $key = str_replace(' ', '_', $key);
+ return strtolower($key);
+ }
+
+}