diff --git a/plugins/importexport/csv/CSVImportExportPlugin.php b/plugins/importexport/csv/CSVImportExportPlugin.php
index 2e2468db016..15fd567d819 100644
--- a/plugins/importexport/csv/CSVImportExportPlugin.php
+++ b/plugins/importexport/csv/CSVImportExportPlugin.php
@@ -3,35 +3,79 @@
/**
* @file plugins/importexport/csv/CSVImportExportPlugin.php
*
- * Copyright (c) 2013-2021 Simon Fraser University
- * Copyright (c) 2003-2021 John Willinsky
+ * Copyright (c) 2013-2025 Simon Fraser University
+ * Copyright (c) 2003-2025 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class CSVImportExportPlugin
*
+ * @ingroup plugins_importexport_csv
+ *
* @brief CSV import/export plugin
*/
namespace APP\plugins\importexport\csv;
use APP\core\Application;
-use APP\core\Request;
use APP\facades\Repo;
-use APP\publicationFormat\PublicationDateDAO;
-use APP\publicationFormat\PublicationFormatDAO;
-use APP\submission\Submission;
+use APP\file\PublicFileManager;
+use APP\plugins\importexport\csv\classes\caches\CachedDaos;
+use APP\plugins\importexport\csv\classes\caches\CachedEntities;
+use APP\plugins\importexport\csv\classes\handlers\CSVFileHandler;
+use APP\plugins\importexport\csv\classes\processors\AuthorsProcessor;
+use APP\plugins\importexport\csv\classes\processors\KeywordsProcessor;
+use APP\plugins\importexport\csv\classes\processors\PublicationDateProcessor;
+use APP\plugins\importexport\csv\classes\processors\PublicationFileProcessor;
+use APP\plugins\importexport\csv\classes\processors\PublicationFormatProcessor;
+use APP\plugins\importexport\csv\classes\processors\PublicationProcessor;
+use APP\plugins\importexport\csv\classes\processors\SubjectsProcessor;
+use APP\plugins\importexport\csv\classes\processors\SubmissionProcessor;
+use APP\plugins\importexport\csv\classes\validations\CategoryValidations;
+use APP\plugins\importexport\csv\classes\validations\InvalidRowValidations;
+use APP\plugins\importexport\csv\classes\validations\SubmissionHeadersValidation;
+use APP\publication\Repository as PublicationService;
use APP\template\TemplateManager;
-use PKP\db\DAORegistry;
+use Exception;
+use PKP\core\PKPRequest;
use PKP\file\FileManager;
use PKP\plugins\ImportExportPlugin;
-use PKP\security\Role;
-use PKP\submission\GenreDAO;
-use PKP\submission\PKPSubmission;
-use PKP\submissionFile\SubmissionFile;
-use PKP\userGroup\UserGroup;
+use PKP\services\PKPFileService;
+use SplFileObject;
class CSVImportExportPlugin extends ImportExportPlugin
{
+ /**
+ * The file directory array map used by the application.
+ *
+ * @var string[]
+ */
+ private array $dirNames;
+
+ /** The default format for the publication file path */
+ private string $format;
+
+ private FileManager $fileManager;
+
+ private PublicFileManager $publicFileManager;
+
+ private PKPFileService $fileService;
+
+ private PublicationService $publicationService;
+
+ private SplFileObject $invalidRowsFile;
+
+ private int $failedRowsCount;
+
+ private int $processedRowsCount;
+
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
/**
* @copydoc Plugin::register()
*
@@ -48,18 +92,24 @@ public function register($category, $path, $mainContextId = null)
* Get the name of this plugin. The name must be unique within
* its category.
*
- * @return string name of plugin
+ * @return String name of plugin
*/
public function getName()
{
return 'CSVImportExportPlugin';
}
+ /**
+ * @copydoc Plugin::getDisplayName()
+ */
public function getDisplayName()
{
return __('plugins.importexport.csv.displayName');
}
+ /**
+ * @copydoc Plugin::getDescription()
+ */
public function getDescription()
{
return __('plugins.importexport.csv.description');
@@ -77,7 +127,7 @@ public function getActions($request, $actionArgs)
* Display the plugin.
*
* @param array $args
- * @param Request $request
+ * @param PKPRequest $request
*/
public function display($args, $request)
{
@@ -92,209 +142,225 @@ public function display($args, $request)
}
/**
- * Execute import/export tasks using the command-line interface.
- *
- * @param array $args Parameters to the plugin
- */
+ * @copydoc ImportExportPlugin::executeCLI()
+ */
public function executeCLI($scriptName, &$args)
{
- $filename = array_shift($args);
- $username = array_shift($args);
+ $startTime = microtime(true);
- if (!$filename || !$username) {
- $this->usage($scriptName);
- exit;
- }
+ [$filename, $username, $basePath] = $this->parseCommandLineArguments($scriptName, $args);
- if (!file_exists($filename)) {
- echo __('plugins.importexport.csv.fileDoesNotExist', ['filename' => $filename]) . "\n";
- exit;
- }
+ $this->validateUser($username);
+ $file = CSVFileHandler::createAndValidateCSVFile($filename);
- $user = Repo::user()->getByUsername($username);
- if (!$user) {
- echo __('plugins.importexport.csv.unknownUser', ['username' => $username]) . "\n";
- exit;
- }
+ $basename = $file->getBasename();
- $pressDao = Application::getContextDAO();
- $publicationFormatDao = DAORegistry::getDAO('PublicationFormatDAO'); /** @var PublicationFormatDAO $publicationFormatDao */
- $genreDao = DAORegistry::getDAO('GenreDAO'); /** @var GenreDAO $genreDao */
- $publicationDateDao = DAORegistry::getDAO('PublicationDateDAO'); /** @var PublicationDateDAO $publicationDateDao */
-
- $file = new \SplFileObject($filename, 'r');
- // Press Path, Author string, title, series path (optional), year, is_edited_volume, locale, URL to PDF, doi (optional)
- $expectedHeaders = ['pressPath', 'authorString', 'title', 'abstract', 'seriesPath', 'year', 'isEditedVolume', 'locale', 'filename', 'doi'];
- $header = $file->fgetcsv() ?: [];
- if (count(array_intersect($expectedHeaders, $header)) !== count($expectedHeaders)) {
- echo __('plugins.importexport.csv.invalidHeader') . "\n";
- exit;
- }
+ $csvForInvalidRowsName = "{$basePath}/invalid_{$basename}";
+ $this->invalidRowsFile = CSVFileHandler::createInvalidCSVFile($csvForInvalidRowsName);
- while (($row = $file->fgetcsv()) !== false) {
- if (trim(implode('', $row)) === '') {
- continue;
+ $this->processedRowsCount = 0;
+ $this->failedRowsCount = 0;
+
+ foreach ($file as $index => $fields) {
+ if (!$index) {
+ continue; // Skip headers
}
- $pressPath = $authorString = $title = $seriesPath = $year = $isEditedVolume = $locale = $filename = $doi = $abstract = null;
- foreach ($header as $index => $field) {
- $$field = $row[$index];
+ if (empty(array_filter($fields))) {
+ continue; // End of file
}
- $press = $pressDao->getByPath($pressPath);
- if (!$press) {
- echo __('plugins.importexport.csv.unknownPress', ['contextPath' => $pressPath]) . "\n";
+ ++$this->processedRowsCount;
+
+ $reason = InvalidRowValidations::validateRowContainAllFields($fields);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fields, $reason, $this->invalidRowsFile, $this->failedRowsCount);
continue;
}
- $supportedLocales = $press->getSupportedSubmissionLocales();
- if (!is_array($supportedLocales) || count($supportedLocales) < 1) {
- $supportedLocales = [$press->getPrimaryLocale()];
+ $fieldsList = array_pad(array_map('trim', $fields), count(SubmissionHeadersValidation::$expectedHeaders), null);
+ $data = (object) array_combine(SubmissionHeadersValidation::$expectedHeaders, $fieldsList);
+
+ $reason = InvalidRowValidations::validateRowHasAllRequiredFields($data);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fields, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+ continue;
+ }
+
+ $press = CachedEntities::getCachedPress($data->pressPath);
+
+ $reason = InvalidRowValidations::validatePresIsValid($data->pressPath, $press);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+ continue;
}
- if (!in_array($locale, $supportedLocales)) {
- echo __('plugins.importexport.csv.unknownLocale', ['locale' => $locale]) . "\n";
+
+ $reason = InvalidRowValidations::validatePressLocales($data->locale, $press);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
continue;
}
+ $pressId = $press->getId();
+
// we need a Genre for the files. Assume a key of MANUSCRIPT as a default.
- $genre = $genreDao->getByKey('MANUSCRIPT', $press->getId());
- if (!$genre) {
- echo __('plugins.importexport.csv.noGenre') . "\n";
+ $genreName = mb_strtoupper($data->genreName ?? 'MANUSCRIPT');
+ $genreId = CachedEntities::getCachedGenreId($pressId, $genreName);
+
+ $reason = InvalidRowValidations::validateGenreIsValid($genreId, $genreName);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
continue;
}
- $authorGroup = UserGroup::withContextIds([$press->getId()])
- ->withContextIds([$press->getId()])
- ->withRoleIds([Role::ROLE_ID_AUTHOR])
- ->isDefault(true)
- ->get()
- ->first();
- if (!$authorGroup) {
- echo __('plugins.importexport.csv.noAuthorGroup', ['press' => $pressPath]) . "\n";
+ $userGroupId = CachedEntities::getCachedUserGroupId($pressId, $data->pressPath);
+
+ $reason = InvalidRowValidations::validateUserGroupId($userGroupId, $data->pressPath);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
continue;
}
- $submission = Repo::submission()->newDataObject();
- $submission->setData('contextId', $press->getId());
+ $filePath = "{$basePath}/{$data->filename}";
- $publication = Repo::publication()->newDataObject();
- $submissionId = Repo::submission()->add($submission, $publication, $press);
- $submission = Repo::submission()->get($submissionId);
- $publication = $submission->getCurrentPublication();
- $publicationId = $publication->getId();
+ $reason = InvalidRowValidations::validateAssetFile($filePath, $data->title);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+ continue;
+ }
- $submission->stampLastActivity();
- $submission->setData('status', PKPSubmission::STATUS_PUBLISHED);
- $submission->setData('workType', $isEditedVolume == 1 ? Submission::WORK_TYPE_EDITED_VOLUME : Submission::WORK_TYPE_AUTHORED_WORK);
- $publication->setData('copyrightNotice', $press->getLocalizedSetting('copyrightNotice'), $locale);
- $submission->setData('locale', $locale);
- $submission->setData('stageId', WORKFLOW_STAGE_ID_PRODUCTION);
- $publication->setData('abstract', $abstract, $locale);
- $submission->setData('submissionProgress', '');
-
- $series = $seriesPath ? Repo::section()->getByPath($seriesPath, $press->getId()) : null;
- if ($series) {
- $publication->setData('seriesId', $series->getId());
+ $pressSeriesId = null;
+ if ($data->seriesPath) {
+ $pressSeriesId = CachedEntities::getCachedSeriesId($data->seriesPath, $pressId);
+
+ $reason = InvalidRowValidations::validateSeriesId($pressSeriesId, $data->seriesPath);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+ continue;
+ }
}
- $contactEmail = $press->getContactEmail();
- $authorString = trim($authorString, '"'); // remove double quotes if present.
- $authors = preg_split('/\s*;\s*/', $authorString);
- $firstAuthor = true;
- foreach ($authors as $authorString) {
- // Examine the author string. Best case is: Given1 Family1 , Given2 Family2 , etc
- // But default to press email address based on press path if not present.
- $givenName = $familyName = $emailAddress = null;
- $authorString = trim($authorString); // whitespace.
- if (!preg_match('/^(\w+)([\w\s]+)?(<([^>]+)>)?$/', $authorString, $matches)) {
- echo __('plugins.importexport.csv.invalidAuthor', ['author' => $authorString]) . "\n";
+ $this->initializeStaticVariables();
+
+ if ($data->bookCoverImage) {
+ $reason = InvalidRowValidations::validateBookCoverImageInRightFormat($data->bookCoverImage);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
continue;
}
- $givenName = trim($matches[1]); // Mandatory
- if (isset($matches[2])) {
- $familyName = trim($matches[2]);
+
+ $srcFilePath = "{$basePath}/{$data->bookCoverImage}";
+
+ $reason = InvalidRowValidations::validateBookCoverImageIsReadable($srcFilePath, $data->title);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+ continue;
}
- $emailAddress = $matches[4] ?? $contactEmail;
- $author = Repo::author()->newDataObject();
- $author->setData('publicationId', $publicationId);
- $author->setSubmissionId($submissionId);
- $author->setUserGroupId($authorGroup->id);
- $author->setGivenName($givenName, $locale);
- $author->setFamilyName($familyName, $locale);
- $author->setEmail($emailAddress);
- if ($firstAuthor) {
- $author->setPrimaryContact(1);
- $firstAuthor = false;
+
+ $sanitizedCoverImageName = str_replace([' ', '_', ':'], '-', mb_strtolower($data->bookCoverImage));
+ $sanitizedCoverImageName = preg_replace('/[^a-z0-9\.\-]+/', '', $sanitizedCoverImageName);
+ $sanitizedCoverImageName = basename($sanitizedCoverImageName);
+
+ $coverImageUploadName = uniqid() . '-' . $sanitizedCoverImageName;
+
+ $destFilePath = $this->publicFileManager->getContextFilesPath($pressId) . '/' . $coverImageUploadName;
+ $bookCoverImageSaved = $this->fileManager->copyFile($srcFilePath, $destFilePath);
+
+ if (!$bookCoverImageSaved) {
+ $reason = __('plugin.importexport.csv.erroWhileSavingBookCoverImage');
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+
+ continue;
}
- Repo::author()->add($author);
- } // Authors done.
- $publication->setData('title', $title, $locale);
- Repo::publication()->edit($publication, []);
- Repo::submission()->edit($submission, []);
+ // Try to create the book cover image thumbnail. If it fails for some reason, add this row as an invalid
+ // and the book cover image will be deleted before jump for the next CSV row.
+ try {
+ $this->publicationService->makeThumbnail(
+ $destFilePath,
+ $this->publicationService->getThumbnailFileName($coverImageUploadName),
+ $press->getData('coverThumbnailsMaxWidth'),
+ $press->getData('coverThumbnailsMaxHeight')
+ );
+ } catch (Exception $exception) {
+ $reason = __('plugin.importexport.csv.errorWhileSavingThumbnail');
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+
+ unlink($destFilePath);
- // Submission is done. Create a publication format for it.
- $publicationFormat = $publicationFormatDao->newDataObject();
- $publicationFormat->setData('publicationId', $publicationId);
- $publicationFormat->setPhysicalFormat(false);
- $publicationFormat->setIsApproved(true);
- $publicationFormat->setIsAvailable(true);
- $publicationFormat->setProductAvailabilityCode('20'); // ONIX code for Available.
- $publicationFormat->setEntryKey('DA'); // ONIX code for Digital
- $publicationFormat->setData('name', 'PDF', $submission->getData('locale'));
- $publicationFormat->setSequence(REALLY_BIG_NUMBER);
- $publicationFormatId = $publicationFormatDao->insertObject($publicationFormat);
-
- if ($doi) {
- $publicationFormat->setStoredPubId('doi', $doi);
+ continue;
+ }
+ }
+
+ $dbCategoryIds = CategoryValidations::getCategoryDataForValidRow($data->categories, $pressId, $data->locale);
+
+ $reason = InvalidRowValidations::validateAllCategoriesExists($dbCategoryIds);
+ if ($reason) {
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+ continue;
+ }
+
+ // All requirements passed. Start processing from here.
+ $submission = SubmissionProcessor::process($data, $pressId);
+ $submissionId = $submission->getId();
+
+ // Copy Submission file. If an error occured, save this row as invalid, delete the saved submission and continue the loop.
+ try {
+ $extension = $this->fileManager->parseFileExtension($data->filename);
+ $submissionDir = sprintf($this->format, $pressId, $submissionId);
+ $fileId = $this->fileService->add($filePath, $submissionDir . '/' . uniqid() . '.' . $extension);
+ } catch (Exception $exception) {
+ $reason = __('plugin.importexport.csv.errorWhileSavingSubmissionFile');
+ CSVFileHandler::processInvalidRows($fieldsList, $reason, $this->invalidRowsFile, $this->failedRowsCount);
+
+ $submissionDao = CachedDaos::getSubmissionDao();
+ $submissionDao->deleteById($submissionId);
+
+ continue;
}
- $publicationFormatDao->updateObject($publicationFormat);
+ $publication = PublicationProcessor::process($submission, $data, $press, $pressSeriesId);
+ $publicationId = $publication->getId();
+ AuthorsProcessor::process($data, $press->getContactEmail(), $submissionId, $publication, $userGroupId);
+
+ // Submission is done. Create a publication format for it.
+ $publicationFormatId = PublicationFormatProcessor::process($submissionId, $publicationId, $extension, $data);
- // Create a publication format date for this publication format.
- $publicationDate = $publicationDateDao->newDataObject();
- $publicationDate->setDateFormat('05'); // List55, YYYY
- $publicationDate->setRole('01'); // List163, Publication Date
- $publicationDate->setDate($year);
- $publicationDate->setPublicationFormatId($publicationFormatId);
- $publicationDateDao->insertObject($publicationDate);
+ PublicationDateProcessor::process($data->year, $publicationFormatId);
// Submission File.
- $fileManager = new FileManager();
- $extension = $fileManager->parseFileExtension($filename);
- $submissionDir = Repo::submissionFile()->getSubmissionDir($press->getId(), $submissionId);
- /** @var \PKP\services\PKPFileService */
- $fileService = app()->get('file');
- $fileId = $fileService->add(
- $filename,
- $submissionDir . '/' . uniqid() . '.' . $extension
- );
-
- $submissionFile = Repo::submissionFile()->newDataObject();
- $submissionFile->setData('submissionId', $submissionId);
- $submissionFile->setData('uploaderUserId', $user->getId());
- $submissionFile->setSubmissionLocale($submission->getData('locale'));
- $submissionFile->setGenreId($genre->getId());
- $submissionFile->setFileStage(SubmissionFile::SUBMISSION_FILE_PROOF);
- $submissionFile->setAssocType(Application::ASSOC_TYPE_REPRESENTATION);
- $submissionFile->setData('assocId', $publicationFormatId);
- $submissionFile->setData('mimetype', 'application/pdf');
- $submissionFile->setData('fileId', $fileId);
-
- // Assume open access, no price.
- $submissionFile->setDirectSalesPrice(0);
- $submissionFile->setSalesType('openAccess');
-
- Repo::submissionFile()->add($submissionFile);
-
- echo __('plugins.importexport.csv.import.submission', ['title' => $title]) . "\n";
+ PublicationFileProcessor::process($data, $submissionId, $filePath, $publicationFormatId, $genreId, $fileId);
+
+ KeywordsProcessor::process($data, $publicationId);
+ SubjectsProcessor::process($data, $publicationId);
+
+ if ($data->bookCoverImage) {
+ PublicationProcessor::updateBookCoverImage($publication, $coverImageUploadName, $data);
+ }
+
+ PublicationProcessor::assignCategoriesToPublication($publicationId, $dbCategoryIds);
+
+ echo __('plugins.importexport.csv.import.submission', ['title' => $data->title]) . "\n";
}
- $file = null;
+
+ if ($this->failedRowsCount === 0) {
+ echo __('plugin.importexport.csv.allDataSuccessfullyImported', ['processedRows' => $this->processedRowsCount]) . "\n\n";
+ unlink($csvForInvalidRowsName);
+ } else {
+ echo __('plugin.importexport.csv.seeInvalidRowsFile', ['processedRows' => $this->processedRowsCount - $this->failedRowsCount, 'failedRows' => $this->failedRowsCount]) . "\n\n";
+ }
+
+ $endTime = microtime(true);
+ $executionTime = $endTime - $startTime;
+
+ $hours = floor($executionTime / 3600);
+ $minutes = floor(($executionTime % 3600) / 60);
+ $seconds = floor($executionTime % 60);
+
+ echo sprintf("Execution time: %dH:%dmin:%dsec\n", $hours, $minutes, $seconds);
}
- /**
- * Display the command-line usage information
- */
+ /** Display the command-line usage information */
public function usage($scriptName)
{
echo __('plugins.importexport.csv.cliUsage', [
@@ -302,4 +368,43 @@ public function usage($scriptName)
'pluginName' => $this->getName()
]) . "\n";
}
+
+ /**
+ * Parse and validate initial command args
+ *
+ * @return string[]
+ */
+ private function parseCommandLineArguments(string $scriptName, array $args): array
+ {
+ $filename = array_shift($args);
+ $username = array_shift($args);
+ $basePath = dirname($filename);
+
+ if (!$filename || !$username) {
+ $this->usage($scriptName);
+ exit(1);
+ }
+
+ return [$filename, $username, $basePath];
+ }
+
+ /** Retrieve and validate the User by username */
+ private function validateUser(string $username): void
+ {
+ if (!CachedEntities::getCachedUser($username)) {
+ echo __('plugins.importexport.csv.unknownUser', ['username' => $username]) . "\n";
+ exit(1);
+ }
+ }
+
+ /** Insert static data that will be used for the submission processing */
+ private function initializeStaticVariables(): void
+ {
+ $this->dirNames ??= Application::getFileDirectories();
+ $this->format ??= trim($this->dirNames['context'], '/') . '/%d/' . trim($this->dirNames['submission'], '/') . '/%d';
+ $this->fileManager ??= new FileManager();
+ $this->publicFileManager ??= new PublicFileManager();
+ $this->fileService ??= app()->get('file');
+ $this->publicationService ??= Repo::publication();
+ }
}
diff --git a/plugins/importexport/csv/README.md b/plugins/importexport/csv/README.md
new file mode 100644
index 00000000000..2341e8cd2a2
--- /dev/null
+++ b/plugins/importexport/csv/README.md
@@ -0,0 +1,129 @@
+# CSV Import Export Plugin
+
+## Table of Contents
+- [Overview](#overview)
+- [Usage Instructions](#usage-instructions)
+- [CSV File Structure and Field Descriptions](#csv-file-structure-and-field-descriptions)
+ - [Required Fields and Headers](#required-fields-and-headers)
+- [Authors Data Organization](#authors-data-organization)
+- [Examples](#examples)
+- [Common Use Cases](#common-use-cases)
+- [Best Practices and Troubleshooting](#best-practices-and-troubleshooting)
+- [Limitations and Special Considerations](#limitations-and-special-considerations)
+
+## Overview
+The CSV Import Export Plugin is a command-line tool for importing submission data from a CSV file into OMP. It allows you to batch-import submissions using a properly formatted CSV file.
+
+## Usage Instructions
+### How to Run
+Use the following command in your terminal:
+```
+php tools/importExport.php CSVImportExportPlugin [path_to_csv_file] [username]
+```
+- **[path_to_csv_file]**: The path to the CSV file containing submission data.
+- **[username]**: The username to assign the imported submissions.
+
+**Example:**
+```
+php tools/importExport.php CSVImportExportPlugin /home/user/submissions.csv johndoe
+```
+
+### Command Parameters Table
+
+| Parameter | Description | Example |
+|-------------------|---------------------------------------------------------|--------------------------------|
+| [path_to_csv_file]| Path to the CSV file containing submission data | /home/user/submissions.csv |
+| [username] | Username to assign the imported submissions | johndoe |
+
+## CSV File Structure and Field Descriptions
+
+The CSV file should have the following structure and fields:
+
+| Column Name | Description | Required | Example Value |
+|-------------------------|--------------------------------------------------------------|:--------:|------------------------------------------------|
+| pressPath | Identifier for the press | Yes | leo |
+| authorString | Authors list; separate multiple authors with semicolons | Yes | "Given1,Family1,email@example.com;John,Doe,john@example.com" |
+| title | Title of the submission | Yes | Title text |
+| abstract | Summary or abstract of the submission | Yes | Abstract text |
+| seriesPath | Series identifier (optional if not applicable) | No | (leave empty if not applicable) |
+| year | Year of the submission | No | 2024 (leave empty if not applicable) |
+| isEditedVolume | Flag indicating if it's an edited volume (1 = Yes, 0 = No) | Yes | 1 (leave empty if not applicable) |
+| locale | Locale code (e.g., en) | Yes | en |
+| filename | Name of the file with submission content | Yes | submission.pdf |
+| doi | Digital Object Identifier (if applicable) | No | 10.1111/hex.12487 |
+| keywords | Keywords separated by semicolons | No | keyword1;keyword2;keyword3 |
+| subjects | Subjects separated by semicolons | No | subject1;subject2 |
+| bookCoverImage | Filename for the cover image | No | coverImage.png |
+| bookCoverImageAltText | Alternative text for the cover image | No | Alt text, with commas |
+| categories | Categories separated by semicolons | No | Category 1;Category 2;Category 3 (leave empty if not applicable) |
+| genreName | Genre of the submission | No | MANUSCRIPT (leave empty if not applicable) |
+
+**Note:** Ensure that fields with commas are properly quoted.
+
+### Required Fields and Headers
+
+The CSV must contain exactly the following headers in the specified order:
+
+**Expected Headers:**
+```
+pressPath,authorString,title,abstract,seriesPath,year,isEditedVolume,locale,filename,doi,keywords,subjects,bookCoverImage,bookCoverImageAltText,categories,genreName
+```
+
+**Required Headers (mandatory):**
+```
+pressPath,authorString,title,abstract,locale,filename
+```
+
+**Warning:** The CSV header order must match exactly as provided in sample.csv. Any deviation, such as additional headers, missing headers, or reordering, will cause the CLI command to crash.
+
+## Authors Data Organization
+
+Author's information is processed via the AuthorsProcessor (see AuthorsProcessor.php). In the CSV, author details should be provided in the `authorString` field following these rules:
+- Multiple authors must be separated by a semicolon (`;`).
+- Each author entry must contain comma-separated values in the following order:
+ - Given Name (required)
+ - Family Name (required)
+ - Email Address (optional; if omitted, the tool defaults to the provided contact email)
+
+**Example:**
+```
+"Given1,Family1,email@example.com;John,Doe,"
+```
+
+**Note:** All assets referenced in the CSV (e.g., files specified in `filename` or `bookCoverImage`) must reside in the same directory as the CSV file.
+
+## Examples
+
+### Command Example
+**Command:**
+```
+php tools/importExport.php CSVImportExportPlugin /home/user/submissions.csv johndoe
+```
+
+**Example Output:**
+```
+Submission: "Title text" successfully imported.
+Submission: "Another Title" successfully imported.
+...
+All submissions imported. 2 successes, 0 failures.
+```
+
+### Sample CSV File Snippet
+```
+pressPath,authorString,title,abstract,seriesPath,year,isEditedVolume,locale,filename,doi,keywords,subjects,bookCoverImage,bookCoverImageAltText,categories,genreName
+leo,"Given1,Family1,given1@example.com;John,Doe,john@example.com",Title text,Abstract text,,2024,1,en,submission.pdf,10.1111/hex.12487,keyword1;keyword2,subject1;subject2,coverImage.png,"Alt text, with commas",Category 1;Category 2,MANUSCRIPT
+```
+
+## Common Use Cases
+- **Batch Importing Submissions:** Import multiple submissions at once using a CSV file.
+- **Data Migration:** Transfer submission data from legacy systems to OMP.
+- **Automated Imports:** Integrate the tool into scripts for periodic data imports.
+
+## Best Practices and Troubleshooting
+- **Verify CSV Structure:** Always check your CSV against the sample structure provided above and ensure it strictly adheres to the required header order.
+- **Check for Required Fields:** Ensure all mandatory fields (e.g., pressPath, authorString, title, abstract, locale, filename) are provided.
+- **Validate Authors Format:** Confirm that the `authorString` field follows the format: Given Name, Family Name, Email (with multiple authors separated by semicolons).
+
+## Limitations and Special Considerations
+- The tool is command-line only; no web interface is available.
+- **Warning:** CSV header mismatches—such as extra headers, missing headers, or headers in an incorrect order—will cause the CLI command to crash. Ensure the CSV exactly matches the header format provided in sample.csv.
diff --git a/plugins/importexport/csv/classes/caches/CachedDaos.php b/plugins/importexport/csv/classes/caches/CachedDaos.php
new file mode 100644
index 00000000000..d0af0c85cb7
--- /dev/null
+++ b/plugins/importexport/csv/classes/caches/CachedDaos.php
@@ -0,0 +1,95 @@
+dao;
+ }
+
+ public static function getSubmissionDao(): SubmissionDAO
+ {
+ return self::$daos['SubmissionDAO'] ??= Repo::submission()->dao;
+ }
+
+ public static function getUserDao(): UserDAO
+ {
+ return self::$daos['UserDAO'] ??= Repo::user()->dao;
+ }
+
+ public static function getPressDao(): PressDAO
+ {
+ return self::$daos['PressDAO'] ??= DAORegistry::getDAO('PressDAO');
+ }
+
+ public static function getGenreDao(): GenreDAO
+ {
+ return self::$daos['GenreDAO'] ??= DAORegistry::getDAO('GenreDAO');
+ }
+
+ public static function getSeriesDao(): SectionDAO
+ {
+ return self::$daos['SeriesDAO'] ??= Repo::section()->dao;
+ }
+
+ public static function getPublicationDao(): PublicationDAO
+ {
+ return self::$daos['PublicationDAO'] ??= Repo::publication()->dao;
+ }
+
+ public static function getAuthorDao(): AuthorDAO
+ {
+ return self::$daos['AuthorDAO'] ??= Repo::author()->dao;
+ }
+
+ public static function getPublicationFormatDao(): PublicationFormatDAO
+ {
+ return self::$daos['PublicationFormatDAO'] ??= DAORegistry::getDAO('PublicationFormatDAO');
+ }
+
+ public static function getPublicationDateDao(): PublicationDateDAO
+ {
+ return self::$daos['PublicationDateDAO'] ??= DAORegistry::getDAO('PublicationDateDAO');
+ }
+
+ public static function getSubmissionFileDao(): SubmissionFileDAO
+ {
+ return self::$daos['SubmissionFileDAO'] ??= Repo::submissionFile()->dao;
+ }
+}
diff --git a/plugins/importexport/csv/classes/caches/CachedEntities.php b/plugins/importexport/csv/classes/caches/CachedEntities.php
new file mode 100644
index 00000000000..fa242718867
--- /dev/null
+++ b/plugins/importexport/csv/classes/caches/CachedEntities.php
@@ -0,0 +1,92 @@
+getByPath($pressPath);
+ }
+
+ public static function getCachedGenreId(int $pressId, string $genreName): ?int
+ {
+ $customKey = "{$genreName}_{$pressId}";
+
+ if (key_exists($customKey, self::$genreIds)) {
+ return self::$genreIds[$customKey];
+ }
+
+ $genreDao = CachedDaos::getGenreDao();
+ $genre = $genreDao->getByKey($genreName, $pressId);
+
+ return self::$genreIds[$customKey] = $genre?->getId();
+ }
+
+ public static function getCachedUserGroupId(int $pressId, string $pressPath): ?int
+ {
+ return self::$userGroupIds[$pressPath] ??= Repo::userGroup()
+ ->getArrayIdByRoleId(Role::ROLE_ID_AUTHOR, $pressId)[0] ?? null;
+ }
+
+ public static function getCachedSeriesId(string $seriesPath, int $pressId): ?int
+ {
+ $customKey = "{$seriesPath}_{$pressId}";
+
+ if (self::$seriesIds[$customKey]) {
+ return self::$seriesIds[$customKey];
+ }
+
+ $seriesDao = CachedDaos::getSeriesDao();
+ $series = $seriesDao->getByPath($seriesPath, $pressId);
+
+ return self::$seriesIds[$customKey] = $series?->getId();
+ }
+
+ public static function getCachedUser(?string $username = null): ?User
+ {
+ if (self::$user) {
+ return self::$user;
+ }
+
+ if (!$username && !self::$user) {
+ throw new Exception('User not found');
+ }
+
+ return self::$user = CachedDaos::getUserDao()->getByUsername($username);
+ }
+}
diff --git a/plugins/importexport/csv/classes/handlers/CSVFileHandler.php b/plugins/importexport/csv/classes/handlers/CSVFileHandler.php
new file mode 100644
index 00000000000..a581e829850
--- /dev/null
+++ b/plugins/importexport/csv/classes/handlers/CSVFileHandler.php
@@ -0,0 +1,89 @@
+setFlags(SplFileObject::READ_CSV);
+
+ $headers = $file->fgetcsv();
+
+ $missingHeaders = array_diff(self::$expectedHeaders, $headers);
+
+ if (count($missingHeaders)) {
+ echo __('plugin.importexport.csv.missingHeadersOnCsv', ['missingHeaders' => $missingHeaders]);
+ exit(1);
+ }
+
+ return $file;
+ }
+
+ public static function createInvalidCSVFile(string $csvForInvalidRowsName): SplFileObject
+ {
+ $file = self::createNewFile($csvForInvalidRowsName, 'w');
+ $file->fputcsv(array_merge(self::$expectedHeaders, ['error']));
+
+ return $file;
+ }
+
+ public static function processInvalidRows(array $data, string $reason, SplFileObject &$invalidRowsFile, int &$failedRowsCount): void
+ {
+ $invalidRowsFile->fputcsv(array_merge($data, [$reason]));
+ $failedRowsCount++;
+ }
+
+
+ private static function createNewFile(string $filename, string $mode): SplFileObject
+ {
+ try {
+ return new SplFileObject($filename, $mode);
+ } catch (Exception $e) {
+ echo $e->getMessage();
+ exit(1);
+ }
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/AuthorsProcessor.php b/plugins/importexport/csv/classes/processors/AuthorsProcessor.php
new file mode 100644
index 00000000000..e6256936333
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/AuthorsProcessor.php
@@ -0,0 +1,57 @@
+authorString));
+
+ foreach ($authorsString as $index => $authorString) {
+ // Examine the author string. Best case is: "Given1,Family1,email@address.com;Given2,Family2,email@address.com", etc
+ // But default to press email address based on press path if not present.
+ $givenName = $familyName = $emailAddress = null;
+ [$givenName, $familyName, $emailAddress] = array_map('trim', explode(',', $authorString));
+
+ if (empty($emailAddress)) {
+ $emailAddress = trim($contactEmail);
+ }
+
+ $author = $authorDao->newDataObject();
+ $author->setSubmissionId($submissionId);
+ $author->setUserGroupId($userGroupId);
+ $author->setGivenName($givenName, $data->locale);
+ $author->setFamilyName($familyName, $data->locale);
+ $author->setEmail($emailAddress);
+ $author->setData('publicationId', $publication->getId());
+ $authorDao->insert($author);
+
+ if (!$index) {
+ $author->setPrimaryContact(true);
+ $authorDao->update($author);
+
+ PublicationProcessor::updatePrimaryContact($publication, $author->getId());
+ }
+ }
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/KeywordsProcessor.php b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php
new file mode 100644
index 00000000000..9c9703590c7
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php
@@ -0,0 +1,39 @@
+keywords));
+
+ if (count($keywordsList) > 0) {
+ Repo::controlledVocab()->insertBySymbolic(
+ ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD,
+ [$data->locale => $keywordsList],
+ Application::ASSOC_TYPE_PUBLICATION,
+ $publicationId
+ );
+ }
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/PublicationDateProcessor.php b/plugins/importexport/csv/classes/processors/PublicationDateProcessor.php
new file mode 100644
index 00000000000..a08d76d54cf
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/PublicationDateProcessor.php
@@ -0,0 +1,35 @@
+newDataObject();
+ $publicationDate->setDateFormat('05'); // List55, YYYY
+ $publicationDate->setRole('01'); // List163, Publication Date
+ $publicationDate->setDate($year);
+ $publicationDate->setPublicationFormatId($publicationFormatId);
+ $publicationDateDao->insertObject($publicationDate);
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/PublicationFileProcessor.php b/plugins/importexport/csv/classes/processors/PublicationFileProcessor.php
new file mode 100644
index 00000000000..ce3eb09c55a
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/PublicationFileProcessor.php
@@ -0,0 +1,55 @@
+newDataObject();
+ $submissionFile->setData('submissionId', $submissionId);
+ $submissionFile->setData('uploaderUserId', CachedEntities::getCachedUser()->getId());
+ $submissionFile->setData('locale', $data->locale);
+ $submissionFile->setData('genreId', $genreId);
+ $submissionFile->setData('fileStage', SubmissionFile::SUBMISSION_FILE_PROOF);
+ $submissionFile->setData('assocType', Application::ASSOC_TYPE_REPRESENTATION);
+ $submissionFile->setData('assocId', $publicationFormatId);
+ $submissionFile->setData('createdAt', Core::getCurrentDate());
+ $submissionFile->setData('updatedAt', Core::getCurrentDate());
+ $submissionFile->setData('mimetype', $mimeType);
+ $submissionFile->setData('fileId', $fileId);
+ $submissionFile->setData('name', pathinfo($filePath, PATHINFO_FILENAME));
+
+ // Assume open access, no price.
+ $submissionFile->setDirectSalesPrice(0);
+ $submissionFile->setSalesType('openAccess');
+
+ $submissionFileDao->insert($submissionFile);
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/PublicationFormatProcessor.php b/plugins/importexport/csv/classes/processors/PublicationFormatProcessor.php
new file mode 100644
index 00000000000..26d697f56be
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/PublicationFormatProcessor.php
@@ -0,0 +1,47 @@
+newDataObject();
+ $publicationFormat->setData('submissionId', $submissionId);
+ $publicationFormat->setData('publicationId', $publicationId);
+ $publicationFormat->setPhysicalFormat(false);
+ $publicationFormat->setIsApproved(true);
+ $publicationFormat->setIsAvailable(true);
+ $publicationFormat->setProductAvailabilityCode('20'); // ONIX code for Available.
+ $publicationFormat->setEntryKey('DA'); // ONIX code for Digital
+ $publicationFormat->setData('name', mb_strtoupper($extension), $data->locale);
+ $publicationFormat->setSequence(REALLY_BIG_NUMBER);
+
+ $publicationFormatId = $publicationFormatDao->insertObject($publicationFormat);
+
+ if ($data->doi) {
+ $publicationFormatDao->changePubId($publicationFormatId, 'doi', $data->doi);
+ }
+
+ return $publicationFormat->getId();
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/PublicationProcessor.php b/plugins/importexport/csv/classes/processors/PublicationProcessor.php
new file mode 100644
index 00000000000..94914ce7bab
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/PublicationProcessor.php
@@ -0,0 +1,85 @@
+abstract);
+ $locale = $data->locale;
+
+ $publication = $publicationDao->newDataObject();
+ $publication->setData('submissionId', $submission->getId());
+ $publication->setData('version', 1);
+ $publication->setData('status', Submission::STATUS_PUBLISHED);
+ $publication->setData('datePublished', Core::getCurrentDate());
+ $publication->setData('abstract', $sanitizedAbstract, $locale);
+ $publication->setData('title', $data->title, $locale);
+ $publication->setData('copyrightNotice', $press->getLocalizedData('copyrightNotice', $locale), $locale);
+
+ if ($data->seriesPath) {
+ $publication->setData('seriesId', $pressSeriesId);
+ }
+
+ $publicationDao->insert($publication);
+
+ // Add this publication as the current one, now that we have its ID
+ $submission->setData('currentPublicationId', $publication->getId());
+
+ $submissionDao = CachedDaos::getSubmissionDao();
+ $submissionDao->update($submission);
+
+ return $publication;
+ }
+
+ public static function updatePrimaryContact(Publication $publication, int $authorId): void
+ {
+ $publication->setData('primaryContactId', $authorId);
+ CachedDaos::getPublicationDao()->update($publication);
+ }
+
+ public static function updateBookCoverImage(Publication $publication, string $uploadName, object $data): void
+ {
+ $coverImage = [];
+
+ $coverImage['uploadName'] = $uploadName;
+ $coverImage['altText'] = $data->bookCoverImageAltText ?? '';
+
+ $publication->setData('coverImage', [$data->locale => $coverImage]);
+
+ CachedDaos::getPublicationDao()->update($publication);
+ }
+
+ /**
+ * Assigns categories to a publication
+ */
+ public static function assignCategoriesToPublication(int $publicationId, array $categoryIds): void
+ {
+ Repo::publication()->assignCategoriesToPublication($publicationId, $categoryIds);
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/SubjectsProcessor.php b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php
new file mode 100644
index 00000000000..c1c5ac989c5
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php
@@ -0,0 +1,39 @@
+subjects));
+
+ if (count($subjectsList) > 0) {
+ Repo::controlledVocab()->insertBySymbolic(
+ ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_SUBJECT,
+ [$data->locale => $subjectsList],
+ Application::ASSOC_TYPE_PUBLICATION,
+ $publicationId
+ );
+ }
+ }
+}
diff --git a/plugins/importexport/csv/classes/processors/SubmissionProcessor.php b/plugins/importexport/csv/classes/processors/SubmissionProcessor.php
new file mode 100644
index 00000000000..1ae49ad8d75
--- /dev/null
+++ b/plugins/importexport/csv/classes/processors/SubmissionProcessor.php
@@ -0,0 +1,45 @@
+newDataObject();
+ $submission->setData('contextId', $pressId);
+ $submission->stampLastActivity();
+ $submission->stampModified();
+ $submission->setData('status', Submission::STATUS_PUBLISHED);
+ $submission->setData('workType', $data->isEditedVolume == 1 ? WORK_TYPE_EDITED_VOLUME : WORK_TYPE_AUTHORED_WORK);
+ $submission->setData('locale', $data->locale);
+ $submission->setData('stageId', WORKFLOW_STAGE_ID_PRODUCTION);
+ $submission->setData('submissionProgress', 0);
+ $submission->setData('abstract', $data->abstract, $data->locale);
+ $submissionDao->insert($submission);
+
+ return $submission;
+ }
+}
diff --git a/plugins/importexport/csv/classes/validations/CategoryValidations.php b/plugins/importexport/csv/classes/validations/CategoryValidations.php
new file mode 100644
index 00000000000..6c2e445539a
--- /dev/null
+++ b/plugins/importexport/csv/classes/validations/CategoryValidations.php
@@ -0,0 +1,61 @@
+table)
+ ->join($categoryDao->settingsTable, $categoryDao->table . '.' . $categoryDao->primaryKeyColumn, '=', $categoryDao->settingsTable . '.' . $categoryDao->primaryKeyColumn)
+ ->where($categoryDao->settingsTable . '.setting_name', '=', 'title')
+ ->where($categoryDao->settingsTable . '.setting_value', '=', trim($categoryTitle))
+ ->where($categoryDao->settingsTable . '.locale', '=', $locale)
+ ->where($categoryDao->table . '.context_id', '=', $pressId)
+ ->first();
+ return $result ? $categoryDao->fromRow($result) : null;
+ })();
+
+ if (!is_null($dbCategory)) {
+ $dbCategoryIds[] = $dbCategory->getId();
+ }
+ }
+
+ $countsMatch = count($categoriesList) === count($dbCategoryIds);
+ return $countsMatch ? $dbCategoryIds : null;
+ }
+}
diff --git a/plugins/importexport/csv/classes/validations/InvalidRowValidations.php b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php
new file mode 100644
index 00000000000..fddfd14910c
--- /dev/null
+++ b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php
@@ -0,0 +1,120 @@
+{$requiredHeader}) {
+ return __('plugins.importexport.csv.requiredFieldsMissing');
+ }
+ }
+
+ return null;
+ }
+
+ public static function validatePresIsValid(string $pressPath, ?Press $press = null): ?string
+ {
+ return !$press
+ ? __('plugins.importexport.csv.unknownPress', ['contextPath' => $pressPath])
+ : null;
+ }
+
+ public static function validatePressLocales(string $locale, Press $press): ?string
+ {
+ $supportedLocales = $press->getSupportedSubmissionLocales();
+ if (!is_array($supportedLocales) || count($supportedLocales) < 1) {
+ $supportedLocales = [$press->getPrimaryLocale()];
+ }
+
+ return !in_array($locale, $supportedLocales)
+ ? __('plugins.importexport.csv.unknownLocale', ['locale' => $locale])
+ : null;
+ }
+
+ public static function validateGenreIsValid(int $genreId, string $genreName): ?string
+ {
+ return !$genreId
+ ? __('plugins.importexport.csv.noGenre', ['manuscript' => $genreName])
+ : null;
+ }
+
+ public static function validateUserGroupId(?int $userGroupId, string $pressPath): ?string
+ {
+ return !$userGroupId
+ ? __('plugins.importexport.csv.noAuthorGroup', ['press' => $pressPath])
+ : null;
+ }
+
+ public static function validateAssetFile(string $filePath, string $title): ?string
+ {
+ return !file_exists($filePath)
+ ? __('plugins.importexport.csv.invalidAssetFilename', ['title' => $title])
+ : null;
+ }
+
+ public static function validateSeriesId(int $seriesId, string $seriesPath): ?string
+ {
+ return !$seriesId
+ ? __('plugin.importexport.csv.seriesPathNotFound', ['seriesPath' => $seriesPath])
+ : null;
+ }
+
+ public static function validateBookCoverImageInRightFormat(string $bookCoverImage): ?string
+ {
+ $coverImgExtension = pathinfo(mb_strtolower($bookCoverImage), PATHINFO_EXTENSION);
+ return !in_array($coverImgExtension, self::$coverImageAllowedTypes)
+ ? __('plugins.importexport.common.error.invalidFileExtension')
+ : null;
+ }
+
+ public static function validateBookCoverImageIsReadable(string $srcFilePath, string $title): ?string
+ {
+ return !is_readable($srcFilePath)
+ ? __('plugins.importexport.csv.invalidCoverImageFilename', ['title' => $title])
+ : null;
+ }
+
+ public static function validateAllCategoriesExists(array|null $categories): ?string
+ {
+ return !$categories
+ ? __('plugins.importexport.csv.allCategoriesMustExists')
+ : null;
+ }
+}
diff --git a/plugins/importexport/csv/classes/validations/SubmissionHeadersValidation.php b/plugins/importexport/csv/classes/validations/SubmissionHeadersValidation.php
new file mode 100644
index 00000000000..00071fa2737
--- /dev/null
+++ b/plugins/importexport/csv/classes/validations/SubmissionHeadersValidation.php
@@ -0,0 +1,53 @@
+, 2023.
-# Germán Huélamo Bautista , 2023, 2024.
-msgid ""
-msgstr ""
-"PO-Revision-Date: 2024-02-29 14:39+0000\n"
-"Last-Translator: Germán Huélamo Bautista \n"
-"Language-Team: French \n"
-"Language: fr_FR\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=n > 1;\n"
-"X-Generator: Weblate 4.18.2\n"
-
-msgid "plugins.importexport.csv.displayName"
-msgstr "Plugin d’importation de contenu délimité par des tabulations"
-
-msgid "plugins.importexport.csv.cliOnly"
-msgstr ""
-"\n"
-"\t\tCe module ne prend actuellement en charge que les opérations en ligne "
-"de commande. Exécutez...\n"
-"\t\t\t
php tools/importExport.php CSVImportExportPlugin
\n"
-"\t\t\t...pour plus d’informations.
\n"
-"\t"
-
-msgid "plugins.importexport.csv.cliUsage"
-msgstr ""
-"Outil de ligne de commande pour l’importation de données CSV dans OMP\n"
-"\t\t\tUtilisation :\n"
-"\t\t\t{$scriptName} [--dry-run] fileName.csv username\n"
-"\t\t\tL’option --dry-run peut être utilisée pour tester sans faire de "
-"changements.\n"
-"\t\t\tSpécifiez le nom d’utilisateur auquel vous souhaitez attribuer les "
-"soumissions.\n"
-
-msgid "plugins.importexport.csv.description"
-msgstr ""
-"Importer les soumissions à partir de données délimitées par des tabulations."
-
-msgid "plugins.importexport.csv.fileDoesNotExist"
-msgstr "Le fichier « {$filename} » n’existe pas. Fermeture de l’application."
-
-msgid "plugins.importexport.csv.unknownUser"
-msgstr "Utilisateur inconnu : « {$username} ». Fermeture de l’application."
-
-msgid "plugins.importexport.csv.unknownLocale"
-msgstr "Locale inconnu : « {$locale} ». Omettre."
-
-msgid "plugins.importexport.csv.unknownPress"
-msgstr "Maison d’édition inconnue : « {$contextPath} ». Omettre."
-
-msgid "plugins.importexport.csv.noGenre"
-msgstr "Le type de livre n’existe pas. Fermeture de l’application."
-
-msgid "plugins.importexport.csv.noAuthorGroup"
-msgstr ""
-"Le groupe d’auteur·e·s par défaut n’existe pas dans la maison d’édition "
-"{$press}. Cette soumission n’est pas prise en compte."
-
-msgid "plugins.importexport.csv.noSeries"
-msgstr ""
-"Le chemin de la collection {$seriesPath} n’existe pas. Impossible de l’"
-"ajouter à cette soumission."
-
-msgid "plugins.importexport.csv.import.submission"
-msgstr "Soumission « {$title} » importée avec succès."
-
-msgid "plugins.importexport.csv.import.success.description"
-msgstr ""
-"L’importation a réussi. Les éléments importés avec succès sont repris dans "
-"la liste ci-dessous."
-
-msgid "plugins.importexport.csv.invalidAuthor"
-msgstr "L'auteur « {$author} » a un format non valide et a été ignoré."
-
-msgid "plugins.importexport.csv.invalidHeader"
-msgstr ""
-"Le fichier CSV est manquant ou a un en-tête invalide, veuillez consulter le "
-"fichier d'exemple « sample.csv » présent dans le dossier du plugin."
diff --git a/plugins/importexport/csv/locale/pt/locale.po b/plugins/importexport/csv/locale/pt/locale.po
deleted file mode 100644
index c6673a80df5..00000000000
--- a/plugins/importexport/csv/locale/pt/locale.po
+++ /dev/null
@@ -1,79 +0,0 @@
-# Carla Marques , 2023.
-# José Carvalho , 2024.
-msgid ""
-msgstr ""
-"PO-Revision-Date: 2024-02-06 09:02+0000\n"
-"Last-Translator: José Carvalho \n"
-"Language-Team: Portuguese (Portugal) \n"
-"Language: pt_PT\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=n > 1;\n"
-"X-Generator: Weblate 4.18.2\n"
-
-msgid "plugins.importexport.csv.displayName"
-msgstr "Plugin de importação de conteúdo delimitado por separadores"
-
-msgid "plugins.importexport.csv.description"
-msgstr ""
-"Importar submissões para editoras com dados delimitados por separadores."
-
-msgid "plugins.importexport.csv.cliUsage"
-msgstr ""
-"Ferramenta de linha de comandos para importação de dados CSV para o OMP\n"
-" Uso:\n"
-" {$scriptName} [--dry-run] fileName.csv username\n"
-" A opção --dry-run pode ser usada para testar sem alterações.\n"
-" Especifique o nome de utilizador que deseja designar às submissões.\n"
-
-msgid "plugins.importexport.csv.fileDoesNotExist"
-msgstr "O ficheiro \"{$filename}\" não existe. A sair."
-
-msgid "plugins.importexport.csv.unknownUser"
-msgstr "Utilizador desconhecido: \"{$username}\". A sair."
-
-msgid "plugins.importexport.csv.unknownLocale"
-msgstr "Idioma desconhecido: \"{$locale}\". Ignorar."
-
-msgid "plugins.importexport.csv.unknownPress"
-msgstr "Editora desconhecida: \"{$contextPath}\". Ignorar."
-
-msgid "plugins.importexport.csv.noGenre"
-msgstr "Não existe género de manuscrito. A sair."
-
-msgid "plugins.importexport.csv.noAuthorGroup"
-msgstr ""
-"Não existe qualquer grupo de autores padrão na editora {$press}. Ignorar "
-"esta submissão."
-
-msgid "plugins.importexport.csv.noSeries"
-msgstr ""
-"O caminho da série {$seriesPath} não existe. Não foi possível adicionar a "
-"esta submissão."
-
-msgid "plugins.importexport.csv.import.submission"
-msgstr "Submissão: '{$title}' importada com sucesso."
-
-msgid "plugins.importexport.csv.import.success.description"
-msgstr ""
-"A importação foi concluída com sucesso. Os itens importados com sucesso "
-"encontram-se na lista abaixo."
-
-msgid "plugins.importexport.csv.cliOnly"
-msgstr ""
-"\n"
-"\t\tEste plugin suporta de momentos apenas operações de linha de "
-"comandos. Execute...\n"
-"
php tools/importExport.php CSVImportExportPlugin
\n"
-" ...para mais informações.\n"
-"\t"
-
-msgid "plugins.importexport.csv.invalidAuthor"
-msgstr "O autor \"{$author}\" tem um formato inválido e foi ignorado."
-
-msgid "plugins.importexport.csv.invalidHeader"
-msgstr ""
-"O ficheiro CSV está em falta ou tem um cabeçalho inválido. Consulte o "
-"ficheiro de amostra \"sample.csv\" presente na pasta do plugin."
diff --git a/plugins/importexport/csv/sample.csv b/plugins/importexport/csv/sample.csv
index b9f042df792..2bc87134713 100644
--- a/plugins/importexport/csv/sample.csv
+++ b/plugins/importexport/csv/sample.csv
@@ -1,2 +1,2 @@
-pressPath,authorString,title,abstract,seriesPath,year,isEditedVolume,locale,filename,doi
-publicknowledge,Author1 Surname1;Author2 Surname2,Title text,Abstract text,,2024,1,en,submission.pdf,https://doi.org/10.1111/hex.12487
+pressPath,authorString,title,abstract,seriesPath,year,isEditedVolume,locale,filename,doi,keywords,subjects,bookCoverImage,bookCoverImageAltText,categories,genreName
+leo,"Given1,Family1,given1.family1@example.com;Name,,name@emailprovider.net;Jean-Luc,,;O'Reilly,,;Juan Pablo,Montoya da Silva,juanpablo@racing.com;Фёдор,Достоевский,fyodor@literature.ru;محمد,علي,muhammad.ali@boxing.sa;John D.,Whatever-Name,john.d@whatevername.com",Title text,Abstract text,,2024,1,en,submission.pdf,10.1111/hex.12487,keyword1;keyword2;keyword3,subject1;subject2,coverImage.png,"Alt text, with commas",Category 1;Category 2;Category 3,MANUSCRIPT
diff --git a/plugins/importexport/csv/version.xml b/plugins/importexport/csv/version.xml
index b0cc8f90cf8..9c7706bc17b 100644
--- a/plugins/importexport/csv/version.xml
+++ b/plugins/importexport/csv/version.xml
@@ -4,8 +4,8 @@