diff --git a/plugins/importexport/csv/CSVImportExportPlugin.php b/plugins/importexport/csv/CSVImportExportPlugin.php new file mode 100644 index 00000000000..710f3bac940 --- /dev/null +++ b/plugins/importexport/csv/CSVImportExportPlugin.php @@ -0,0 +1,152 @@ +getEnabled()) { + $this->addLocaleData(); + } + return $success; + } + + /** + * @copydoc Plugin::getDisplayName() + */ + public function getDisplayName(): string + { + return __('plugins.importexport.csv.displayName'); + } + + /** + * @copydoc Plugin::getDescription() + */ + public function getDescription(): string + { + return __('plugins.importexport.csv.description'); + } + + /** + * @copydoc Plugin::getName() + */ + public function getName(): string + { + return 'CSVImportExportPlugin'; + } + + /** + * @copydoc PKPImportExportPlugin::usage + */ + public function usage($scriptName) + { + echo __('plugins.importexport.csv.cliUsage', [ + 'scriptName' => $scriptName, + 'pluginName' => $this->getName() + ]) . "\n\n"; + echo __('plugins.importexport.csv.cliUsage.examples', [ + 'scriptName' => $scriptName, + 'pluginName' => $this->getName() + ]) . "\n\n"; + } + + /** + * @see PKPImportExportPlugin::executeCLI() + */ + public function executeCLI($scriptName, &$args) + { + $startTime = microtime(true); + $this->command = array_shift($args); + $this->username = array_shift($args); + $this->sourceDir = array_shift($args); + $this->sendWelcomeEmail = array_shift($args) ?? false; + + if (! in_array($this->command, ['issues', 'users']) || !$this->sourceDir || !$this->username) { + $this->usage($scriptName); + exit(1); + } + + if (! is_dir($this->sourceDir)) { + echo __('plugins.importexport.csv.unknownSourceDir', ['sourceDir' => $this->sourceDir]) . "\n"; + exit(1); + } + + $this->validateUser(); + + match ($this->command) { + 'issues' => (new IssueCommand($this->sourceDir, $this->user))->run(), + 'users' => (new UserCommand($this->sourceDir, $this->user, $this->sendWelcomeEmail))->run(), + default => throw new \InvalidArgumentException("Comando inválido: {$this->command}"), + }; + + $endTime = microtime(true); + $executionTime = $endTime - $startTime; + echo "Executed in: {$executionTime} seconds\n"; + } + + /** + * Retrieve and validate the User by username + */ + private function validateUser(): void + { + $this->user = $this->getUser(); + if (!$this->user) { + echo __('plugins.importexport.csv.unknownUser', ['username' => $this->username]) . "\n"; + exit(1); + } + } + + /** + * Retrives an user by username + */ + private function getUser(): ?User + { + return Repo::user()->getByUsername($this->username); + } +} diff --git a/plugins/importexport/csv/README.md b/plugins/importexport/csv/README.md new file mode 100644 index 00000000000..93e282f1ef3 --- /dev/null +++ b/plugins/importexport/csv/README.md @@ -0,0 +1,176 @@ +# CSV Import/Export Plugin + +## Table of Contents +- [Overview](#overview) +- [Usage](#usage) + - [Parameters](#parameters) + - [Examples](#examples) +- [CSV Fields](#csv-fields) + - [Issues Import](#issues-import) + - [File Structure](#file-structure) + - [Field Descriptions](#field-descriptions) + - [Users Import](#users-import) + - [Field Descriptions](#field-descriptions-1) +- [Multiple Values](#multiple-values) + +## Overview + +This plugin allows you to import issues and users into OJS using CSV files. + +The tool processes each row (issue or user) individually. If an error is found during processing: +1. The problematic row will be saved to a new CSV file +2. A new column called 'reason' will be added to this CSV, explaining what went wrong +3. The tool will continue processing the remaining rows +4. At the end of processing, you can check the error CSV file to fix and reprocess the failed entries + +For example, if your original CSV had 10 issues and 2 failed, you'll get: +- 8 issues successfully imported +- A new CSV file containing the 2 failed rows with their error descriptions +- Processing will complete for all rows, regardless of individual failures + +## Usage + +```bash +php tools/importExport.php CSVImportExportPlugin [command] [username] [directory] [sendWelcomeEmail] +``` + +### Parameters: + +- `command`: Either 'issues' or 'users' +- `username`: Username of an existing user in the system who will perform the import +- `directory`: Path to the directory containing CSV files. Can be absolute (e.g., `/full/path/to/directory`) or relative to the current directory (e.g., `./relative/path`). For issues import, this directory must contain both the CSV files and all referenced assets (PDFs, images, etc.) +- `sendWelcomeEmail`: (Optional, users only) Set to true to send welcome emails to imported users + +### Examples: + +```bash +# Import issues +php tools/importExport.php CSVImportExportPlugin issues admin /path/to/csv/directory + +# Import users with welcome email +php tools/importExport.php CSVImportExportPlugin users admin /path/to/csv/directory true +``` + +## CSV Fields + +### Issues Import + +Complete field list (in order): +``` +journalPath,locale,articleTitle,articlePrefix,articleSubtitle,articleAbstract,articleGalleyFilename,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,suppFilenames,suppLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage +``` + +Required fields only: +``` +journalPath,locale,articleTitle,articleAbstract,articleGalleyFilename,authors,issueTitle,issueVolume,issueNumber,issueYear,datePublished +``` + +> **Important**: Even when using only required fields, always maintain the same field order as shown in the "Complete field list". For unused optional fields, keep them empty but preserve their position in the CSV. + +#### File Structure + +All files referenced in the CSV must be placed in the same directory as your CSV file. Required files: +- The CSV file(s) containing issue metadata +- Article files referenced in `articleGalleyFilename` column +- Galley files referenced in `suppFilenames` column +- Cover images referenced in `coverImageFilename` column + +For example, if your CSV contains: +``` +articleGalleyFilename=articleGalley.pdf,suppFilenames=suppFile1.pdf;suppFile2.pdf,coverImageFilename=cover.png +``` + +Your directory should contain: +``` +/your/import/directory/ + ├── issues.csv + ├── articleGalley.pdf + ├── suppFile1.pdf + ├── suppFile2.pdf + └── cover.png +``` + +Field descriptions: + +- `journalPath`: Journal path identifier +- `locale`: Content language (e.g., 'en') +- `articleTitle`: Title of the article +- `articlePrefix`: Prefix for the article title +- `articleSubtitle`: Subtitle of the article +- `articleAbstract`: Article abstract +- `articleGalleyFilename`: Name of the article's primary galley file +- `authors`: Author information with the following rules: + - Each author's data must follow the format: "GivenName,FamilyName,email,affiliation" + - Multiple authors must be separated by semicolons (;) + - FamilyName, email, and affiliation are optional and can be left empty (e.g., "John,,,") + - If email is empty, the system will use the primary contact email + - The first author in the list will be set as the primary contact + - Example with multiple authors: + ``` + "John,Doe,john@email.com,University A;Jane,,jane@email.com,;Robert,Smith,," + ``` +- `keywords`: Keywords (semicolon-separated) +- `subjects`: Subjects (semicolon-separated) +- `coverage`: Coverage information +- `categories`: Categories (semicolon-separated) +- `doi`: Digital Object Identifier +- `coverImageFilename`: Cover image file name +- `coverImageAltText`: Alt text for cover image +- `suppFilenames`: Names of supplementary files (semicolon-separated). Note only supplementary files that doesn't require dependent files are supported on this field. +- `suppLabels`: Labels for supplementary files (semicolon-separated). Must have the same number of items as `suppFilenames` to ensure correct pairing between files and labels +- `genreName`: Genre name +- `sectionTitle`: Journal section title +- `sectionAbbrev`: Section abbreviation +- `issueTitle`: Title of the issue +- `issueVolume`: Issue volume number +- `issueNumber`: Issue number +- `issueYear`: Year of publication +- `issueDescription`: Description of the issue +- `datePublished`: Publication date (YYYY-MM-DD) +- `startPage`: Starting page number +- `endPage`: Ending page number + +### Users Import + +Complete field list (in order): +``` +journalPath,firstname,lastname,email,affiliation,country,username,tempPassword,roles,reviewInterests +``` + +Required fields only: +``` +journalPath,firstname,lastname,email,roles +``` + +> **Important**: Even when using only required fields, always maintain the same field order as shown in the "Complete field list". For unused optional fields, keep them empty but preserve their position in the CSV. + +Field descriptions: + +- `journalPath`: Journal path identifier +- `firstname`: User's first name +- `lastname`: User's last name +- `email`: User's email address +- `affiliation`: User's institutional affiliation +- `country`: Two-letter country code +- `username`: Desired username +- `tempPassword`: Temporary password +- `roles`: User roles (semicolon-separated, e.g., "Reader;Author") +- `reviewInterests`: Review interests (semicolon-separated) + +For users import, only the CSV(s) file(s) is(are) needed in your import directory. + +## Multiple Values + +For fields that accept multiple values: +- Use semicolons (;) to separate multiple values within a field +- For authors field: + - Format for each author: "GivenName,FamilyName,email,affiliation" + - FamilyName, email, and affiliation are optional (can be left empty) + - If email is empty, the system will use the primary contact email + - The first author in the list will be set as the primary contact + - Multiple authors must be separated by semicolons + - Example: "John,Doe,john@email.com,University A;Jane,,jane@email.com,;Robert,Smith,," +- For galleys: + - Both `suppFilenames` and `suppLabels` support multiple values + - They must have the same number of items to ensure correct pairing between files and their labels + - Example: if `suppFilenames=suppFile1.pdf;suppFile2.pdf`, then `suppLabels` must be something like `PDF;PDF` diff --git a/plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php b/plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php new file mode 100644 index 00000000000..c3017f86624 --- /dev/null +++ b/plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php @@ -0,0 +1,81 @@ +getByPath($journalPath); + } + + /** + * Retrieves a cached userGroup ID by journalId. Returns null if an error occurs. + */ + public static function getCachedUserGroupId(string $journalPath, int $journalId): ?int + { + return self::$userGroupIds[$journalPath] ??= Repo::userGroup() + ->getByRoleIds([Role::ROLE_ID_AUTHOR], $journalId) + ->first()?->getId(); + } + + public static function getCachedUserByEmail(string $email): ?User + { + return self::$users[$email] ??= Repo::user()->getByEmail($email); + } + + public static function getCachedUserByUsername(string $username): ?User + { + return self::$users[$username] ??= Repo::user()->getByUsername($username); + } + + public static function getCachedUserGroupsByJournalId(int $journalId): array + { + return self::$userGroups[$journalId] ??= Repo::userGroup()->getCollector() + ->filterByContextIds([$journalId]) + ->getMany() + ->toArray(); + } + + public static function getCachedUserGroupByName(string $name, int $journalId, string $locale): ?UserGroup + { + $userGroups = self::getCachedUserGroupsByJournalId($journalId); + + foreach ($userGroups as $userGroup) { + if (mb_strtolower($userGroup->getName($locale)) === mb_strtolower($name)) { + return $userGroup; + } + } + + return null; + } + + /** + * Retrieves a cached genre ID by genreName and journalId. Returns null if an error occurs. + */ + public static function getCachedGenreId(string $genreName, int $journalId): ?int + { + return self::$genreIds[$genreName] ??= CachedDaos::getGenreDao() + ->getByKey($genreName, $journalId) + ?->getId(); + } + + /** + * Retrieves a cached Category by categoryName and journalId. Returns null if an error occurs. + */ + public static function getCachedCategory(string $categoryName, int $journalId): ?Category + { + $result = Repo::category()->getCollector() + ->filterByContextIds([$journalId]) + ->filterByPaths([$categoryName]) + ->limit(1) + ->getMany() + ->toArray(); + + return self::$categories[$categoryName] ??= (array_values($result)[0] ?? null); + } + + /** + * Retrieves a cached Issue by issue data and journalId. Returns null if an error occurs. + */ + public static function getCachedIssue(object $data, int $journalId): ?Issue + { + $customIssueDescription = "{$data->issueVolume}_{$data->issueNumber}_{$data->issueYear}"; + $result = Repo::issue()->getCollector() + ->filterByContextIds([$journalId]) + ->filterByNumbers([$data->issueNumber]) + ->filterByVolumes([$data->issueVolume]) + ->filterByYears([$data->issueYear]) + ->limit(1) + ->getMany() + ->toArray(); + + return self::$issues[$customIssueDescription] ??= (array_values($result)[0] ?? null); + } + + /** + * Retrieves a cached Section by sectionTitle, sectionAbbrev, and journalId. Returns null if an error occurs. + */ + public static function getCachedSection(string $sectionTitle, string $sectionAbbrev, int $journalId): ?Section + { + $result = Repo::section()->getCollector() + ->filterByContextIds([$journalId]) + ->filterByTitles([$sectionTitle]) + ->filterByAbbrevs([$sectionAbbrev]) + ->limit(1) + ->getMany() + ->toArray(); + + return self::$sections["{$sectionTitle}_{$sectionAbbrev}"] ??= (array_values($result)[0] ?? null); + } +} diff --git a/plugins/importexport/csv/classes/commands/IssueCommand.php b/plugins/importexport/csv/classes/commands/IssueCommand.php new file mode 100644 index 00000000000..090bd4252b1 --- /dev/null +++ b/plugins/importexport/csv/classes/commands/IssueCommand.php @@ -0,0 +1,393 @@ +expectedRowSize = count(RequiredIssueHeaders::$issueHeaders); + $this->sourceDir = $sourceDir; + $this->user = $user; + } + + public function run(): void + { + foreach (new DirectoryIterator($this->sourceDir) as $fileInfo) { + if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'csv') { + continue; + } + + $filePath = $fileInfo->getPathname(); + + $file = CSVFileHandler::createReadableCSVFile($filePath); + + if (is_null($file)) { + continue; + } + + $basename = $fileInfo->getBasename(); + $invalidCsvFile = CSVFileHandler::createCSVFileInvalidRows( + $this->sourceDir, + "invalid_{$basename}", + RequiredIssueHeaders::$issueHeaders + ); + + if (is_null($invalidCsvFile)) { + continue; + } + + $this->processedRows = 0; + $this->failedRows = 0; + + foreach ($file as $index => $fields) { + if (!$index || empty(array_filter($fields))) { + continue; // Skip headers or end of file + } + + ++$this->processedRows; + + $reason = InvalidRowValidations::validateRowContainAllFields($fields, $this->expectedRowSize); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $data = (object) array_combine( + RequiredIssueHeaders::$issueHeaders, + array_pad(array_map('trim', $fields), $this->expectedRowSize, null) + ); + + $reason = InvalidRowValidations::validateRowHasAllRequiredFields($data, [RequiredIssueHeaders::class, 'validateRowHasAllRequiredFields']); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $fieldsList = array_pad($fields, $this->expectedRowSize, null); + + $reason = InvalidRowValidations::validateArticleFileIsValid($data->articleGalleyFilename, $this->sourceDir); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + if ($data->suppFilenames) { + $reason = InvalidRowValidations::validateArticleGalleys( + $data->suppFilenames, + $data->suppLabels, + $this->sourceDir + ); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + } + + $journal = CachedEntities::getCachedJournal($data->journalPath); + + $reason = InvalidRowValidations::validateJournalIsValid($journal, $data->journalPath); + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $reason = InvalidRowValidations::validateJournalLocale($journal, $data->locale); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + // we need a Genre for the files. Assume a key of SUBMISSION as a default. + $genreName = mb_strtoupper($data->genreName ?? 'SUBMISSION'); + $genreId = CachedEntities::getCachedGenreId($genreName, $journal->getId()); + $reason = InvalidRowValidations::validateGenreIdValid($genreId, $genreName); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $userGroupId = CachedEntities::getCachedUserGroupId($data->journalPath, $journal->getId()); + $reason = InvalidRowValidations::validateUserGroupId($userGroupId, $data->journalPath); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $this->initializeStaticVariables(); + + if ($data->coverImageFilename) { + $reason = InvalidRowValidations::validateCoverImageIsValid($data->coverImageFilename, $this->sourceDir); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $sanitizedCoverImageName = str_replace([' ', '_', ':'], '-', mb_strtolower($data->coverImageFilename)); + $sanitizedCoverImageName = PKPString::regexp_replace('/[^a-z0-9\.\-]+/', '', $sanitizedCoverImageName); + $coverImageUploadName = uniqid() . '-' . basename($sanitizedCoverImageName); + + $destFilePath = $this->publicFileManager->getContextFilesPath($journal->getId()) . '/' . $coverImageUploadName; + $srcFilePath = "{$this->sourceDir}/{$data->coverImageFilename}"; + $bookCoverImageSaved = $this->fileManager->copyFile($srcFilePath, $destFilePath); + + if (!$bookCoverImageSaved) { + $reason = __('plugin.importexport.csv.erroWhileSavingBookCoverImage'); + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + + continue; + } + } + + // All requirements passed. Start processing from here. + $submission = SubmissionProcessor::process($journal->getId(), $data); + + // Copy Submission file. If an error occured, save this row as invalid, + // delete the saved submission and continue the loop. + $articleGalleyFilenameId = $this->saveSubmissionFile( + $data->articleGalleyFilename, + $journal->getId(), + $submission->getId(), + $invalidCsvFile, + __('plugins.importexport.csv.errorWhileSavingSubmissionFile'), + $fieldsList + ); + + if (is_null($articleGalleyFilenameId)) { + continue; + } + + // // Array to store each galley ID to its respective galley file + $galleyIds = []; + foreach (array_map('trim', explode(';', $data->suppFilenames)) as $galleyFile) { + $galleyFileId = $this->saveSubmissionFile( + $galleyFile, + $journal->getId(), + $submission->getId(), + $invalidCsvFile, + __('plugins.importexport.csv.errorWhileSavingSubmissionGalley', ['galley' => $galleyFile]), + $fieldsList + ); + + if (is_null($galleyFileId)) { + $this->fileService->delete($articleGalleyFilenameId); + + foreach ($galleyIds as $galleyItem) { + $this->fileService->delete($galleyItem['id']); + } + + continue; + } + + $galleyIds[] = ['file' => $galleyFile, 'id' => $galleyFileId]; + } + + $publication = PublicationProcessor::process($submission, $data, $journal); + AuthorsProcessor::process($data, $journal->getContactEmail(), $submission->getId(), $publication, $userGroupId); + + // Process submission file data into the database + $articleFileCompletePath = "{$this->sourceDir}/{$data->articleGalleyFilename}"; + SubmissionFileProcessor::process( + $data->locale, + $this->user->getId(), + $submission->getId(), + $articleFileCompletePath, + $genreId, + $articleGalleyFilenameId + ); + + // Now, process the submission file for all article galleys + $galleyLabelsArray = array_map('trim', explode(';', $data->galleyLabels)); + + for ($i = 0; $i < count($galleyLabelsArray); $i++) { + $galleyItem = $galleyIds[$i]; + $galleyLabel = $galleyLabelsArray[$i]; + + $this->handleArticleGalley( + $galleyItem, + $data, + $submission->getId(), + $genreId, + $galleyLabel, + $publication->getId() + ); + } + + KeywordsProcessor::process($data, $publication->getId()); + SubjectsProcessor::process($data, $publication->getId()); + + if ($data->coverage) { + PublicationProcessor::updateCoverage($publication, $data->coverage, $data->locale); + } + + if ($data->coverImageFilename) { + PublicationProcessor::updateCoverImage($publication, $data, $coverImageUploadName); + } + + if ($data->categories) { + CategoriesProcessor::process($data->categories, $data->locale, $journal->getId(), $publication->getId()); + } + + $issue = IssueProcessor::process($journal->getId(), $data); + PublicationProcessor::updateIssueId($publication, $issue->getId()); + + $section = SectionsProcessor::process($data, $journal->getId()); + PublicationProcessor::updateSectionId($publication, $section->getId()); + } + + echo __('plugins.importexpot.csv.fileProcessFinished', [ + 'filename' => $fileInfo->getFilename(), + 'processedRows' => $this->processedRows, + 'failedRows' => $this->failedRows, + ]) . "\n"; + + if (!$this->failedRows) { + unlink($this->sourceDir . '/' . "invalid_{$basename}"); + } + } + } + + /** + * 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 ??= Services::get('file'); + } + + /** + * Save a submission file. If an error occurred, the method will delete the submission already saved + * and return null. + */ + private function saveSubmissionFile( + string $filePath, + int $journalId, + int $submissionId, + SplFileObject &$invalidCsvFile, + string $reason, + array $fieldsList + ): ?int { + try { + $extension = $this->fileManager->parseFileExtension($filePath); + $submissionDir = sprintf($this->format, $journalId, $submissionId); + $completePath = "{$this->sourceDir}/{$filePath}"; + return $this->fileService->add($completePath, $submissionDir . '/' . uniqid() . '.' . $extension); + } catch (Exception $e) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fieldsList, $this->expectedRowSize, $reason, $this->failedRows); + + $submissionDao = Repo::submission()->dao; + $submissionDao->deleteById($submissionId); + + return null; + } + } + + /** + * Process data for the galley submission file and galley into the database. + */ + private function handleArticleGalley( + array $galleyItem, + object $data, + int $submissionId, + int $genreId, + string $galleyLabel, + int $publicationId + ): void { + $galleyCompletePath = "{$this->sourceDir}/{$galleyItem['file']}"; + $galleyExtension = $this->fileManager->parseFileExtension($galleyCompletePath); + + $submissionFile = SubmissionFileProcessor::process( + $data->locale, + $this->user->getId(), + $submissionId, + $galleyCompletePath, + $genreId, + $galleyItem['id'], + ); + + // Now that we have the submission file ID, it's time to process the galley itself. + $galleyId = GalleyProcessor::process($submissionFile->getId(), $data, $galleyLabel, $publicationId, $galleyExtension); + SubmissionFileProcessor::updateAssocInfo($submissionFile, $galleyId); + } +} diff --git a/plugins/importexport/csv/classes/commands/UserCommand.php b/plugins/importexport/csv/classes/commands/UserCommand.php new file mode 100644 index 00000000000..abad3ad2ca1 --- /dev/null +++ b/plugins/importexport/csv/classes/commands/UserCommand.php @@ -0,0 +1,169 @@ +expectedRowSize = count(RequiredUserHeaders::$userHeaders); + $this->sourceDir = $sourceDir; + $this->senderEmailUser = $user; + $this->sendWelcomeEmail = $sendWelcomeEmail; + } + + public function run(): void + { + foreach (new DirectoryIterator($this->sourceDir) as $fileInfo) { + if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'csv') { + continue; + } + + $filePath = $fileInfo->getPathname(); + + $file = CSVFileHandler::createReadableCSVFile($filePath); + + if (is_null($file)) { + continue; + } + + $basename = $fileInfo->getBasename(); + $invalidCsvFile = CSVFileHandler::createCSVFileInvalidRows($this->sourceDir, "invalid_{$basename}", RequiredUserHeaders::$userHeaders); + + if (is_null($invalidCsvFile)) { + continue; + } + + $this->processedRows = 0; + $this->failedRows = 0; + + foreach ($file as $index => $fields) { + if (!$index || empty(array_filter($fields))) { + continue; // Skip headers or end of file + } + + ++$this->processedRows; + + $reason = InvalidRowValidations::validateRowContainAllFields($fields, $this->expectedRowSize); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $fieldsList = array_pad(array_map('trim', $fields), $this->expectedRowSize, null); + $data = (object) array_combine(RequiredUserHeaders::$userHeaders, $fieldsList); + + $reason = InvalidRowValidations::validateRowHasAllRequiredFields($data, [RequiredUserHeaders::class, 'validateRowHasAllRequiredFields']); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $journal = CachedEntities::getCachedJournal($data->journalPath); + + $reason = InvalidRowValidations::validateJournalIsValid($journal, $data->journalPath); + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + $existingUserByEmail = CachedEntities::getCachedUserByEmail($data->email); + if (!is_null($existingUserByEmail)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, __('plugins.importexport.csv.userAlreadyExistsWithEmail', ['email' => $data->email]), $this->failedRows); + continue; + } + + if ($data->username) { + $existingUserByUsername = CachedEntities::getCachedUserByUsername($data->username); + if (!is_null($existingUserByUsername)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, __('plugins.importexport.csv.userAlreadyExistsWithUsername', ['username' => $data->username]), $this->failedRows); + continue; + } + } + + $data->username = UsersProcessor::getValidUsername($data->firstname, $data->lastname); + + $roles = array_map('trim', explode(';', $data->roles)); + + $reason = InvalidRowValidations::validateAllUserGroupsAreValid($roles, $journal->getId(), $journal->getPrimaryLocale()); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + if (is_null($data->tempPassword)) { + $data->tempPassword = Validation::generatePassword(); + } + + $user = UsersProcessor::process($data, $journal->getPrimaryLocale()); + $userId = $user->getId(); + + $userInterests = array_map('trim', explode(';', $data->reviewInterests)); + UserInterestsProcessor::process($userInterests, $userId); + + UserGroupsProcessor::process($roles, $userId, $journal->getId(), $journal->getPrimaryLocale()); + + if ($this->sendWelcomeEmail) { + WelcomeEmailHandler::sendWelcomeEmail($journal, $user, $this->senderEmailUser, $data->tempPassword); + } + } + + echo __('plugins.importexpot.csv.fileProcessFinished', [ + 'filename' => $fileInfo->getFilename(), + 'processedRows' => $this->processedRows, + 'failedRows' => $this->failedRows, + ]) . "\n"; + + if (!$this->failedRows) { + unlink($this->sourceDir . '/' . "invalid_{$basename}"); + } + } + } +} diff --git a/plugins/importexport/csv/classes/handlers/CSVFileHandler.php b/plugins/importexport/csv/classes/handlers/CSVFileHandler.php new file mode 100644 index 00000000000..40ec6dbf5ae --- /dev/null +++ b/plugins/importexport/csv/classes/handlers/CSVFileHandler.php @@ -0,0 +1,72 @@ +setFlags(SplFileObject::READ_CSV); + return $file; + } catch (Exception $e) { + echo __('plugins.importexport.csv.couldNotOpenFile', [ + 'filePath' => $filePath, + 'errorMessage' => $e->getMessage(), + ]) . "\n"; + return null; + } + } + + /** + * Create a new writable SplFileObject for invalid rows from a unique CSV file. Return null if an error occurred. + */ + public static function createCSVFileInvalidRows(string $sourceDir, string $filename, array $requiredHeaders): ?SplFileObject + { + try { + $invalidRowsFile = new SplFileObject($sourceDir . '/' . $filename, 'a+'); + $invalidRowsFile->fputcsv(array_merge($requiredHeaders, ['error'])); + + return $invalidRowsFile; + } catch (Exception $e) { + echo $e->getMessage() . "\n\n"; + echo __('plugins.importexport.csv.couldNotCreateFile', ['filename' => $sourceDir . '/' . $filename]) . "\n"; + return null; + } + } + + /** + * Add a new row on the invalid csv file + */ + public static function processFailedRow( + SplFileObject &$invalidRowsCsvFile, + array $fields, + int $rowSize, + string $reason, + int &$failedRows + ): void { + $invalidRowsCsvFile->fputcsv(array_merge(array_pad($fields, $rowSize, null), [$reason])); + ++$failedRows; + } +} diff --git a/plugins/importexport/csv/classes/handlers/WelcomeEmailHandler.php b/plugins/importexport/csv/classes/handlers/WelcomeEmailHandler.php new file mode 100644 index 00000000000..91334b16c01 --- /dev/null +++ b/plugins/importexport/csv/classes/handlers/WelcomeEmailHandler.php @@ -0,0 +1,56 @@ +recipients($recipient); + $mailable->sender($sender); + $mailable->replyTo($context->getData('contactEmail'), $context->getData('contactName')); + $template = Repo::emailTemplate()->getByKey($context->getId(), UserCreated::getEmailTemplateKey()); + $mailable->body($template->getLocalizedData('body')); + $mailable->subject($template->getLocalizedData('subject')); + + try { + Mail::send($mailable); + } catch (TransportException $e) { + $notificationMgr = new NotificationManager(); + $notificationMgr->createTrivialNotification( + $sender->getId(), + PKPNotification::NOTIFICATION_TYPE_ERROR, + ['contents' => __('email.compose.error')] + ); + error_log($e->getMessage()); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/AuthorsProcessor.php b/plugins/importexport/csv/classes/processors/AuthorsProcessor.php new file mode 100644 index 00000000000..6d1a02ab03a --- /dev/null +++ b/plugins/importexport/csv/classes/processors/AuthorsProcessor.php @@ -0,0 +1,74 @@ +dao; + $authorsString = array_map('trim', explode(';', $data->authors)); + + foreach ($authorsString as $index => $authorString) { + /** + * Examine the author string. The pattern is: "GivenName,FamilyName,email@email.com,affiliation". + * + * If the article has more than one author, it must separate the authors by a semicolon (;). Example: + * ";". + * + * Fields familyName, email, and affiliation are optional and can be left as empty fields. E.g.: + * "GivenName,,,". + * + * By default, if an author doesn't have an email, the primary contact email will be used in its place. + */ + $givenName = $familyName = $emailAddress = null; + [$givenName, $familyName, $emailAddress, $affiliation] = array_map('trim', explode(',', $authorString)); + + if (empty($emailAddress)) { + $emailAddress = $contactEmail; + } + + $author = $authorDao->newDataObject(); + $author->setSubmissionId($submissionId); + $author->setUserGroupId($userGroupId); + $author->setGivenName($givenName, $data->locale); + $author->setFamilyName($familyName, $data->locale); + $author->setEmail($emailAddress); + $author->setAffiliation($affiliation, $data->locale); + $author->setData('publicationId', $publication->getId()); + $authorDao->insert($author); + + if (!$index) { + $author->setPrimaryContact(true); + $authorDao->update($author); + + PublicationProcessor::updatePrimaryContactId($publication, $author->getId()); + } + } + } +} diff --git a/plugins/importexport/csv/classes/processors/CategoriesProcessor.php b/plugins/importexport/csv/classes/processors/CategoriesProcessor.php new file mode 100644 index 00000000000..1473fae1879 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/CategoriesProcessor.php @@ -0,0 +1,51 @@ +dao; + + if (is_null($category)) { + $category = $categoryDao->newDataObject(); + $category->setContextId($journalId); + $category->setTitle($categoryPath, $locale); + $category->setParentId(null); + $category->setSequence(REALLY_BIG_NUMBER); + $category->setPath($lowerCategoryPath); + + $categoryDao->insert($category); + } + + $categoryDao->insertPublicationAssignment($category->getId(), $publicationId); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/GalleyProcessor.php b/plugins/importexport/csv/classes/processors/GalleyProcessor.php new file mode 100644 index 00000000000..61083d6ad1b --- /dev/null +++ b/plugins/importexport/csv/classes/processors/GalleyProcessor.php @@ -0,0 +1,51 @@ +dao; + + $galley = $galleyDao->newDataObject(); + $galley->setData('submissionFileId', $submissionFileId); + $galley->setData('publicationId', $publicationId); + $galley->setLabel($label); + $galley->setLocale($data->locale); + $galley->setIsApproved(true); + $galley->setSequence(REALLY_BIG_NUMBER); + $galley->setName(mb_strtoupper($extension), $data->locale); + + if ($data->doi) { + $galley->setStoredPubId('doi', $data->doi); + } + + $galleyDao->insert($galley); + return $galley->getId(); + } +} diff --git a/plugins/importexport/csv/classes/processors/IssueProcessor.php b/plugins/importexport/csv/classes/processors/IssueProcessor.php new file mode 100644 index 00000000000..dffa342301f --- /dev/null +++ b/plugins/importexport/csv/classes/processors/IssueProcessor.php @@ -0,0 +1,60 @@ +dao; + $sanitizedIssueDescription = PKPString::stripUnsafeHtml($data->issueDescription); + + $issue = $issueDao->newDataObject(); + $issue->setJournalId($journalId); + $issue->setVolume($data->issueVolume); + $issue->setNumber($data->issueNumber); + $issue->setYear($data->issueYear); + $issue->setShowVolume($data->issueVolume); + $issue->setShowNumber($data->issueNumber); + $issue->setShowYear($data->issueYear); + $issue->setShowTitle(1); + $issue->setPublished(true); + $issue->setDatePublished(Core::getCurrentDate()); + $issue->setTitle($data->issueTitle, $data->locale); + $issue->setDescription($sanitizedIssueDescription, $data->locale); + $issue->stampModified(); + + // Assume open access, no price. + $issue->setAccessStatus(Issue::ISSUE_ACCESS_OPEN); + $issueDao->insert($issue); + } + + return $issue; + } +} diff --git a/plugins/importexport/csv/classes/processors/KeywordsProcessor.php b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php new file mode 100644 index 00000000000..e41f09be271 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php @@ -0,0 +1,35 @@ +locale => array_map('trim', explode(';', $data->keywords))]; + + if (count($keywordsList[$data->locale]) > 0) { + $submissionKeywordDao = CachedDaos::getSubmissionKeywordDao(); + $submissionKeywordDao->insertKeywords($keywordsList, $publicationId); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/PublicationProcessor.php b/plugins/importexport/csv/classes/processors/PublicationProcessor.php new file mode 100644 index 00000000000..986f8a81851 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/PublicationProcessor.php @@ -0,0 +1,120 @@ +dao; + $sanitizedAbstract = PKPString::stripUnsafeHtml($data->articleAbstract); + $locale = $data->locale; + + $publication = $publicationDao->newDataObject(); + $publication->stampModified(); + $publication->setData('submissionId', $submission->getId()); + $publication->setData('version', 1); + $publication->setData('status', Submission::STATUS_PUBLISHED); + $publication->setData('datePublished', $data->datePublished); + $publication->setData('abstract', $sanitizedAbstract, $locale); + $publication->setData('title', $data->articleTitle, $locale); + $publication->setData('copyrightNotice', $journal->getLocalizedData('copyrightNotice', $locale), $locale); + + if ($data->articleSubtitle) { + $publication->setData('subtitle', $data->articleSubtitle, $locale); + } + + if ($data->articlePrefix) { + $publication->setData('prefix', $data->articlePrefix, $locale); + } + + if ($data->startPage && $data->endPage) { + $publication->setData('pages', "{$data->startPage}-{$data->endPage}"); + } + + $publicationDao->insert($publication); + + SubmissionProcessor::updateCurrentPublicationId($submission, $publication->getId()); + + return $publication; + } + + /** + * Updates the primary contact ID for the publication + */ + public static function updatePrimaryContactId(Publication $publication, int $authorId): void + { + self::updatePublicationAttribute($publication, 'primaryContactId', $authorId); + } + + /** + * Updates the coverage for the publication + */ + public static function updateCoverage(Publication $publication, string $coverage, string $locale): void + { + self::updatePublicationAttribute($publication, 'coverage', $coverage, $locale); + } + + /** + * Updates the cover image for the publication + */ + public static function updateCoverImage(Publication $publication, object $data, string $uploadName): void + { + $coverImage = []; + + $coverImage['uploadName'] = $uploadName; + $coverImage['altText'] = $data->coverImageAltText ?? ''; + + self::updatePublicationAttribute($publication, 'coverImage', [$data->locale => $coverImage]); + } + + /** + * Updates the issue ID for the publication + */ + public static function updateIssueId(Publication $publication, int $issueId): void + { + self::updatePublicationAttribute($publication, 'issueId', $issueId); + } + + /** + * Updates the section ID for the publication + */ + public static function updateSectionId(Publication $publication, int $sectionId): void + { + self::updatePublicationAttribute($publication, 'sectionId', $sectionId); + } + + /** + * Updates a specific attribute of the publication + */ + public static function updatePublicationAttribute(Publication $publication, string $attribute, mixed $data, ?string $locale = null): void + { + $publication->setData($attribute, $data, $locale); + + $publicationDao = Repo::publication()->dao; + $publicationDao->update($publication); + } +} diff --git a/plugins/importexport/csv/classes/processors/SectionsProcessor.php b/plugins/importexport/csv/classes/processors/SectionsProcessor.php new file mode 100644 index 00000000000..2a8173b34a2 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/SectionsProcessor.php @@ -0,0 +1,55 @@ +sectionTitle, $data->sectionAbbrev, $journalId); + + if (is_null($section)) { + $sectionDao = Repo::section()->dao; + + $section = $sectionDao->newDataObject(); + $section->setContextId($journalId); + $section->setSequence(REALLY_BIG_NUMBER); + $section->setEditorRestricted(false); + $section->setMetaIndexed(true); + $section->setMetaReviewed(true); + $section->setAbstractsNotRequired(false); + $section->setHideTitle(false); + $section->setHideAuthor(false); + $section->setIsInactive(false); + $section->setTitle($data->sectionTitle); + $section->setAbbrev(mb_strtoupper(trim($data->sectionAbbrev))); + $section->setIdentifyType(''); + $section->setPolicy(''); + + $sectionDao->insert($section); + } + + return $section; + } +} diff --git a/plugins/importexport/csv/classes/processors/SubjectsProcessor.php b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php new file mode 100644 index 00000000000..7476b4c8d8f --- /dev/null +++ b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php @@ -0,0 +1,35 @@ +locale => array_map('trim', explode(';', $data->subjects))]; + + if (count($subjectsList[$data->locale]) > 0) { + $submissionSubjectDao = CachedDaos::getSubmissionSubjectDao(); + $submissionSubjectDao->insertSubjects($subjectsList, $publicationId); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/SubmissionFileProcessor.php b/plugins/importexport/csv/classes/processors/SubmissionFileProcessor.php new file mode 100644 index 00000000000..c792a9c6a0a --- /dev/null +++ b/plugins/importexport/csv/classes/processors/SubmissionFileProcessor.php @@ -0,0 +1,72 @@ +dao; + + $submissionFile = $submissionFileDao->newDataObject(); + $submissionFile->setData('submissionId', $submissionId); + $submissionFile->setData('uploaderUserId', $userId); + $submissionFile->setData('locale', $locale); + $submissionFile->setData('genreId', $genreId); + $submissionFile->setData('fileStage', SubmissionFile::SUBMISSION_FILE_PROOF); + $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), $locale); + + // Assume open access, no price. + $submissionFile->setDirectSalesPrice(0); + $submissionFile->setSalesType('openAccess'); + + $submissionFileDao->insert($submissionFile); + return $submissionFile; + } + + /** + * Updates the association information for the submission file + */ + public static function updateAssocInfo(SubmissionFile $submissionFile, int $galleyId): void + { + $submissionFile->setData('assocType', Application::ASSOC_TYPE_REPRESENTATION); + $submissionFile->setData('assocId', $galleyId); + + $submissionFileDao = Repo::submissionFile()->dao; + $submissionFileDao->update($submissionFile); + } +} diff --git a/plugins/importexport/csv/classes/processors/SubmissionProcessor.php b/plugins/importexport/csv/classes/processors/SubmissionProcessor.php new file mode 100644 index 00000000000..88b6cf5c862 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/SubmissionProcessor.php @@ -0,0 +1,53 @@ +dao; + + $submission = $submissionDao->newDataObject(); + $submission->setData('contextId', $journalId); + $submission->stampLastActivity(); + $submission->stampModified(); + $submission->setData('status', Submission::STATUS_PUBLISHED); + $submission->setData('locale', $data->locale); + $submission->setData('stageId', WORKFLOW_STAGE_ID_PRODUCTION); + $submission->setData('submissionProgress', '0'); + $submission->setData('abstract', $data->articleAbstract, $data->locale); + $submissionDao->insert($submission); + + return $submission; + } + + /** + * Updates the current publication ID for the submission + */ + public static function updateCurrentPublicationId(Submission $submission, int $publicationId): void + { + $submission->setData('currentPublicationId', $publicationId); + Repo::submission()->dao->update($submission); + } +} diff --git a/plugins/importexport/csv/classes/processors/UserGroupsProcessor.php b/plugins/importexport/csv/classes/processors/UserGroupsProcessor.php new file mode 100644 index 00000000000..cffa08393e6 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/UserGroupsProcessor.php @@ -0,0 +1,35 @@ +assignUserToGroup($userId, $userGroup->getId()); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/UserInterestsProcessor.php b/plugins/importexport/csv/classes/processors/UserInterestsProcessor.php new file mode 100644 index 00000000000..99eb53d9b8a --- /dev/null +++ b/plugins/importexport/csv/classes/processors/UserInterestsProcessor.php @@ -0,0 +1,31 @@ +setUserInterests($reviewInterests, $userId); + } +} diff --git a/plugins/importexport/csv/classes/processors/UsersProcessor.php b/plugins/importexport/csv/classes/processors/UsersProcessor.php new file mode 100644 index 00000000000..9584d4c8bf2 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/UsersProcessor.php @@ -0,0 +1,68 @@ +dao; + + $user = $userDao->newDataObject(); + $user->setGivenName($data->firstname, $locale); + $user->setFamilyName($data->lastname, $locale); + $user->setEmail($data->email); + $user->setAffiliation($data->affiliation, $locale); + $user->setCountry($data->country); + $user->setUsername($data->username); + $user->setMustChangePassword(true); + $user->setDateRegistered(Core::getCurrentDate()); + $user->setPassword(Validation::encryptCredentials($data->username, $data->tempPassword)); + + $userDao->insert($user); + + return $user; + } + + public static function getValidUsername(string $firstname, string $lastname): ?string + { + $letters = range('a', 'z'); + + do { + $randomLetters = ''; + for ($i = 0; $i < 3; $i++) { + $randomLetters .= $letters[array_rand($letters)]; + } + + $username = mb_strtolower(mb_substr($firstname, 0, 1) . $lastname . $randomLetters); + + $existingUser = CachedEntities::getCachedUserByUsername($username); + + } while (!is_null($existingUser)); + + return $username; + } +} diff --git a/plugins/importexport/csv/classes/validations/InvalidRowValidations.php b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php new file mode 100644 index 00000000000..a5347e6ed11 --- /dev/null +++ b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php @@ -0,0 +1,168 @@ + $suppFilename]); + } + } + + return null; + } + + /** + * Validates whether the journal is valid for the CSV row. Returns the reason if an error occurred, + * or null if everything is correct. + */ + public static function validateJournalIsValid(?Journal $journal, string $journalPath): ?string + { + return !$journal ? __('plugins.importexport.csv.unknownJournal', ['journalPath' => $journalPath]) : null; + } + + /** + * Validates if the journal supports the locale provided in the CSV row. Returns the reason if an error occurred + * or null if everything is correct. + */ + public static function validateJournalLocale(Journal $journal, string $locale): ?string + { + $supportedLocales = $journal->getSupportedSubmissionLocales(); + if (!is_array($supportedLocales) || count($supportedLocales) < 1) { + $supportedLocales = [$journal->getPrimaryLocale()]; + } + + return !in_array($locale, $supportedLocales) + ? __('plugins.importexport.csv.unknownLocale', ['locale' => $locale]) + : null; + } + + /** + * Validates if a genre exists for the name provided in the CSV row. Returns the reason if an error occurred + * or null if everything is correct. + */ + public static function validateGenreIdValid(?int $genreId, string $genreName): ?string + { + return !$genreId ? __('plugins.importexport.csv.noGenre', ['genreName' => $genreName]) : null; + } + + /** + * Validates if the user group ID is valid. Returns the reason if an error occurred + * or null if everything is correct. + */ + public static function validateUserGroupId(?int $userGroupId, string $journalPath): ?string + { + return !$userGroupId + ? __('plugins.importexport.csv.noAuthorGroup', ['journal' => $journalPath]) + : null; + } + + public static function validateAllUserGroupsAreValid(array $roles, int $journalId, string $locale): ?string + { + $userGroups = CachedEntities::getCachedUserGroupsByJournalId($journalId); + + $allDbRoles = 0; + foreach ($roles as $role) { + $matchingGroups = array_filter($userGroups, function ($userGroup) use ($role, $locale) { + return mb_strtolower($userGroup->getName($locale)) === mb_strtolower($role); + }); + $allDbRoles += count($matchingGroups); + } + + return $allDbRoles !== count($roles) + ? __('plugins.importexport.csv.roleDoesntExist', ['role' => $role]) + : null; + } +} diff --git a/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php new file mode 100644 index 00000000000..9f192fea856 --- /dev/null +++ b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php @@ -0,0 +1,87 @@ +{$requiredHeader}) { + return false; + } + } + + return true; + } +} diff --git a/plugins/importexport/csv/classes/validations/RequiredUserHeaders.php b/plugins/importexport/csv/classes/validations/RequiredUserHeaders.php new file mode 100644 index 00000000000..2d2d65bcd03 --- /dev/null +++ b/plugins/importexport/csv/classes/validations/RequiredUserHeaders.php @@ -0,0 +1,63 @@ +{$requiredHeader}) { + return false; + } + } + + return true; + } +} diff --git a/plugins/importexport/csv/examples/issues/issues_example.csv b/plugins/importexport/csv/examples/issues/issues_example.csv new file mode 100644 index 00000000000..7098ecb45d1 --- /dev/null +++ b/plugins/importexport/csv/examples/issues/issues_example.csv @@ -0,0 +1,2 @@ +journalPath,locale,articleTitle,articlePrefix,articleSubtitle,articleAbstract,articleGalleyFilename,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,suppFilenames,suppLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage +leo,en,"Article Title",PREF,"Article Subtitle","Article Abstract",articleGalley.pdf,"Guilherme Henrique,Lemes de Godoy,guilherme@email.com,My Affiliation;Євген,Шевченко,yevhen@email.com,","keyword1;keyword2;keyword with spaces","subject1;subject2;subject with spaces",coverage,"category1;category2;Category with spaces",10.5678/4jg7k43,coverImage.png,"Alt Text","suppFile1.pdf;suppFile2.pdf","PDF;PDF","SUBMISSION",Articles,ART,"Issue Title",1,1,2024,"Issue Description",2024-09-09,1,15 diff --git a/plugins/importexport/csv/examples/users/users_example.csv b/plugins/importexport/csv/examples/users/users_example.csv new file mode 100644 index 00000000000..7e4411ae7c6 --- /dev/null +++ b/plugins/importexport/csv/examples/users/users_example.csv @@ -0,0 +1,3 @@ +journalPath,firstname,lastname,email,affiliation,country,username,tempPassword,roles,reviewInterests +leo,Homer,Simpson,email@email.ca,UBC,CA,hsimpson,temppassword123,"Reader;Author","interest one; interest two" +leo,Bart,Simpson,email2@email.ca,UBC,CA,bsimpson,temppassword123,"Reader","interest two; interest three" diff --git a/plugins/importexport/csv/index.php b/plugins/importexport/csv/index.php new file mode 100644 index 00000000000..146b6e72666 --- /dev/null +++ b/plugins/importexport/csv/index.php @@ -0,0 +1,16 @@ +\n" +"Choose one of the following options:\n" +" - users - import users from CSV file\n" +" - issues - import issues from CSV file\n\n" +"username - the username of the user to whom imported issues should be assigned.\n" +"pathToCsvFile - the path to the CSV file to be imported.\n" +"sendWelcomeEmail - optional parameter, if set to true, a welcome email will be sent to the imported users.\n" + +msgid "plugins.importexport.csv.cliUsage.examples" +msgstr "Examples:\n" +"{$scriptName} users admin /home/user/import_files/users.csv\n" +"{$scriptName} issues admin /home/user/import_files/issues.csv\n" +"{$scriptName} users admin /home/user/import_files/users.csv true\n" +"{$scriptName} issues admin /home/user/import_files/issues.csv true\n" + +msgid "plugins.importexport.csv.unknownUser" +msgstr "Unknown User: \"{$username}\". Exiting." + +msgid "plugins.importexport.csv.unknownSourceDir" +msgstr "Invalid source dir: \"{$sourceDir}\". Please verify this param on CLI. Exiting." + +msgid "plugins.importexport.csv.couldNotOpenFile" +msgstr "Could not read file: \"{$filePath}\". Error: \"{$errorMessage}\"." + +msgid "plugins.importexport.csv.couldNotCreateFile" +msgstr "Could not create the file \"{$filename}\"." + +msgid "plugins.importexport.csv.rowDoesntContainAllFields" +msgstr "Row doesn't contain all fields." + +msgid "plugins.importexport.csv.verifyRequiredFieldsForThisRow" +msgstr "Verify the required fields for this row." + +msgid "plugins.importexport.csv.unknownJournal" +msgstr "Unknown journal with path \"{$journalPath}\"." + +msgid "plugins.importexport.csv.unknownLocale" +msgstr "Unknown locale or locale not supported by this journal: \"{$locale}\"" + +msgid "plugins.importexport.csv.invalidArticleFile" +msgstr "Invalid article file for this submission." + +msgid "plugins.importexport.csv.invalidCoverImage" +msgstr "Invalid cover image for this submission." + +msgid "plugins.importexport.csv.invalidGalleyFile" +msgstr "Invalid galley file \"{$filename}\" for this submission." + +msgid "plugins.importexport.csv.invalidNumberOfLabelsAndGalleys" +msgstr "Galley files don't have the same amount as the labels. Please verify it for this row." + +msgid "plugins.importexport.csv.noAuthorGroup" +msgstr "There is no default author group in the journal {$journal}." + +msgid "plugins.importexpot.csv.fileProcessFinished" +msgstr "Process for file \"{$filename}\" finished. {$processedRows} rows processed. {$failedRows} rows with error." + +msgid "plugins.importexport.csv.invalidBookCoverImage" +msgstr "The cover image has an invalid format for this row." + +msgid "plugins.importexport.csv.erroWhileSavingBookCoverImage" +msgstr "An error occurred while saving the book cover image." + +msgid "plugins.importexport.csv.noGenre" +msgstr "There is no {$genreName} genre." + +msgid "plugins.importexport.csv.errorWhileSavingSubmissionFile" +msgstr "An error occurred while saving the Submission file." + +msgid "plugins.importexport.csv.errorWhileSavingSubmissionGalley" +msgstr "An error occurred while saving the galley {$galley}." + +msgid "plugins.importexport.csv.userAlreadyExistsWithEmail" +msgstr "User already exists with email \"{$email}\"." + +msgid "plugins.importexport.csv.userAlreadyExistsWithUsername" +msgstr "User already exists with username \"{$username}\"." + +msgid "plugins.importexport.csv.roleDoesntExist" +msgstr "Role \"{$role}\" doesn't exist."