From 27d3f701a6d62ca4031d333671de4ada00f2d715 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Tue, 18 Feb 2025 11:26:10 +0000 Subject: [PATCH 1/7] implements a CSV importexport tool --- .../csv/CSVImportExportPlugin.php | 157 +++++++ plugins/importexport/csv/README.md | 176 ++++++++ .../classes/cachedAttributes/CachedDaos.php | 54 +++ .../cachedAttributes/CachedEntities.php | 169 ++++++++ .../csv/classes/commands/IssueCommand.php | 391 ++++++++++++++++++ .../csv/classes/commands/UserCommand.php | 169 ++++++++ .../csv/classes/handlers/CSVFileHandler.php | 72 ++++ .../classes/handlers/WelcomeEmailHandler.php | 56 +++ .../classes/processors/AuthorsProcessor.php | 74 ++++ .../processors/CategoriesProcessor.php | 51 +++ .../classes/processors/GalleyProcessor.php | 51 +++ .../csv/classes/processors/IssueProcessor.php | 60 +++ .../classes/processors/KeywordsProcessor.php | 41 ++ .../processors/PublicationProcessor.php | 128 ++++++ .../classes/processors/SectionsProcessor.php | 55 +++ .../classes/processors/SubjectsProcessor.php | 41 ++ .../processors/SubmissionFileProcessor.php | 72 ++++ .../processors/SubmissionProcessor.php | 53 +++ .../processors/UserGroupsProcessor.php | 34 ++ .../processors/UserInterestsProcessor.php | 32 ++ .../csv/classes/processors/UsersProcessor.php | 68 +++ .../validations/InvalidRowValidations.php | 168 ++++++++ .../validations/RequiredIssueHeaders.php | 86 ++++ .../validations/RequiredUserHeaders.php | 63 +++ .../csv/examples/issues/issues_example.csv | 2 + .../csv/examples/users/users_example.csv | 3 + plugins/importexport/csv/index.php | 16 + plugins/importexport/csv/locale/en/locale.po | 102 +++++ 28 files changed, 2444 insertions(+) create mode 100644 plugins/importexport/csv/CSVImportExportPlugin.php create mode 100644 plugins/importexport/csv/README.md create mode 100644 plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php create mode 100644 plugins/importexport/csv/classes/cachedAttributes/CachedEntities.php create mode 100644 plugins/importexport/csv/classes/commands/IssueCommand.php create mode 100644 plugins/importexport/csv/classes/commands/UserCommand.php create mode 100644 plugins/importexport/csv/classes/handlers/CSVFileHandler.php create mode 100644 plugins/importexport/csv/classes/handlers/WelcomeEmailHandler.php create mode 100644 plugins/importexport/csv/classes/processors/AuthorsProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/CategoriesProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/GalleyProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/IssueProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/KeywordsProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/PublicationProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/SectionsProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/SubjectsProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/SubmissionFileProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/SubmissionProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/UserGroupsProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/UserInterestsProcessor.php create mode 100644 plugins/importexport/csv/classes/processors/UsersProcessor.php create mode 100644 plugins/importexport/csv/classes/validations/InvalidRowValidations.php create mode 100644 plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php create mode 100644 plugins/importexport/csv/classes/validations/RequiredUserHeaders.php create mode 100644 plugins/importexport/csv/examples/issues/issues_example.csv create mode 100644 plugins/importexport/csv/examples/users/users_example.csv create mode 100644 plugins/importexport/csv/index.php create mode 100644 plugins/importexport/csv/locale/en/locale.po diff --git a/plugins/importexport/csv/CSVImportExportPlugin.php b/plugins/importexport/csv/CSVImportExportPlugin.php new file mode 100644 index 00000000000..847f808ccdd --- /dev/null +++ b/plugins/importexport/csv/CSVImportExportPlugin.php @@ -0,0 +1,157 @@ +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("Invalid command: {$this->command}"), + }; + + $endTime = microtime(true); + $executionTime = $endTime - $startTime; + + $hours = floor($executionTime / 3600); + $minutes = floor(($executionTime % 3600) / 60); + $seconds = floor($executionTime % 60); + + echo sprintf("%dH:%dmin:%dsec\n", $hours, $minutes, $seconds); + } + + /** + * 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..461f6663b82 --- /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,articleFilepath,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,galleyFilenames,galleyLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage +``` + +Required fields only: +``` +journalPath,locale,articleTitle,articleAbstract,articleFilepath,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 `articleFilepath` column +- Galley files referenced in `galleyFilenames` column +- Cover images referenced in `coverImageFilename` column + +For example, if your CSV contains: +``` +articleFilepath=article1.pdf,galleyFilenames=galleys1.pdf;galleys2.pdf,coverImageFilename=cover.png +``` + +Your directory should contain: +``` +/your/import/directory/ + ├── issues.csv + ├── article1.pdf + ├── galleys1.pdf + ├── galleys2.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 +- `articleFilepath`: Path to the article's main 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 +- `galleyFilenames`: Names of galley files (semicolon-separated) +- `galleyLabels`: Labels for galleys (semicolon-separated). Must have the same number of items as `galleyFilenames` 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 `galleyFilenames` and `galleyLabels` support multiple values + - They must have the same number of items to ensure correct pairing between files and their labels + - Example: if `galleyFilenames=article.pdf;article.html`, then `galleyLabels` must be something like `PDF;HTML` diff --git a/plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php b/plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php new file mode 100644 index 00000000000..bba1ed6cf48 --- /dev/null +++ b/plugins/importexport/csv/classes/cachedAttributes/CachedDaos.php @@ -0,0 +1,54 @@ +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()->getArrayIdByRoleId(Role::ROLE_ID_AUTHOR, $journalId)[0] ?? null; + } + + 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] ??= UserGroup::query() + ->select('user_groups.*') + ->join('user_group_settings', 'user_groups.user_group_id', '=', 'user_group_settings.user_group_id') + ->withContextIds([$journalId]) + ->get() + ->toArray(); + } + + public static function getCachedUserGroupByName(string $name, int $journalId, string $locale): mixed + { + $uniquekey = "{$name}_{$locale}_{$journalId}"; + if (isset(self::$userGroups[$uniquekey])) { + return self::$userGroups[$uniquekey]; + } + + $userGroups = self::getCachedUserGroupsByJournalId($journalId); + + foreach ($userGroups as $userGroup) { + if (mb_strtolower($userGroup['name'][$locale]) === mb_strtolower($name)) { + return self::$userGroups[$uniquekey] = $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..7730a768dae --- /dev/null +++ b/plugins/importexport/csv/classes/commands/IssueCommand.php @@ -0,0 +1,391 @@ +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->articleFilepath, $this->sourceDir); + + if (!is_null($reason)) { + CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); + continue; + } + + if ($data->galleyFilenames) { + $reason = InvalidRowValidations::validateArticleGalleys( + $data->galleyFilenames, + $data->galleyLabels, + $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 = preg_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. + $articleFilePathId = $this->saveSubmissionFile( + $data->articleFilepath, + $journal->getId(), + $submission->getId(), + $invalidCsvFile, + __('plugins.importexport.csv.errorWhileSavingSubmissionFile'), + $fieldsList + ); + + if (is_null($articleFilePathId)) { + continue; + } + + // // Array to store each galley ID to its respective galley file + $galleyIds = []; + foreach (array_map('trim', explode(';', $data->galleyFilenames)) 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($articleFilePathId); + + 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->articleFilepath}"; + SubmissionFileProcessor::process( + $data->locale, + $this->user->getId(), + $submission->getId(), + $articleFileCompletePath, + $genreId, + $articleFilePathId + ); + + // 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 ??= app()->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..cab597bc3ae --- /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, $user); + + 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..1c1fd4e0ab0 --- /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..139f0f7cf73 --- /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(), + Notification::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..5c11383a51d --- /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->setData('affiliation', $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..7fc58a5bb6a --- /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); + } + + PublicationProcessor::assignCategoriesToPublication($publicationId, [$category->getId()]); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/GalleyProcessor.php b/plugins/importexport/csv/classes/processors/GalleyProcessor.php new file mode 100644 index 00000000000..2868467c753 --- /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..dcbc4f1dacb --- /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..e0013bea836 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php @@ -0,0 +1,41 @@ +locale => array_map('trim', explode(';', $data->keywords))]; + + if (count($keywordsList[$data->locale]) > 0) { + Repo::controlledVocab()->insertBySymbolic( + ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD, + $keywordsList[$data->locale], + Application::ASSOC_TYPE_PUBLICATION, + $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..2e9dc2209f3 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/PublicationProcessor.php @@ -0,0 +1,128 @@ +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); + } + + /** + * 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/SectionsProcessor.php b/plugins/importexport/csv/classes/processors/SectionsProcessor.php new file mode 100644 index 00000000000..2e77872d6d6 --- /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..7267a8f17c1 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php @@ -0,0 +1,41 @@ +locale => array_map('trim', explode(';', $data->subjects))]; + + if (count($subjectsList[$data->locale]) > 0) { + Repo::controlledVocab()->insertBySymbolic( + ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_SUBJECT, + $subjectsList[$data->locale], + Application::ASSOC_TYPE_PUBLICATION, + $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..66fa6a3bd02 --- /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..4c5d07e0ed0 --- /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..70da4bf7ece --- /dev/null +++ b/plugins/importexport/csv/classes/processors/UserGroupsProcessor.php @@ -0,0 +1,34 @@ +assignUserToGroup($userId, $userGroup['userGroupId']); + } + } +} diff --git a/plugins/importexport/csv/classes/processors/UserInterestsProcessor.php b/plugins/importexport/csv/classes/processors/UserInterestsProcessor.php new file mode 100644 index 00000000000..a86c0d7e4d2 --- /dev/null +++ b/plugins/importexport/csv/classes/processors/UserInterestsProcessor.php @@ -0,0 +1,32 @@ +setInterestsForUser($user, $reviewInterests); + } +} diff --git a/plugins/importexport/csv/classes/processors/UsersProcessor.php b/plugins/importexport/csv/classes/processors/UsersProcessor.php new file mode 100644 index 00000000000..6808cef2d88 --- /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..7cd2e97457b --- /dev/null +++ b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php @@ -0,0 +1,168 @@ + $galleyFilename]); + } + } + + 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['name'][$locale]) === mb_strtolower($role); + }); + $allDbRoles += count($matchingGroups); + } + + return $allDbRoles !== count($roles) + ? __('plugins.importexport.csv.roleDoesntExist', ['role' => implode(', ', $roles)]) + : 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..55ecb34e140 --- /dev/null +++ b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php @@ -0,0 +1,86 @@ +{$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..9616b81cd5e --- /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..4b90442464b --- /dev/null +++ b/plugins/importexport/csv/examples/issues/issues_example.csv @@ -0,0 +1,2 @@ +journalPath,locale,articleTitle,articlePrefix,articleSubtitle,articleAbstract,articleFilepath,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,galleyFilenames,galleyLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage +leo,en,Title,PREF,articleSubtitle,Abstract,file.pdf,"GivenName,FamilyName,email@email.com,My Affiliation",keyword1,subject1,coverage,category1,10.5678/4jg7k43,coverImage.png,"Alt Text",galleyPdf.pdf,PDF,Articles,ART,"Issue 1, Vol. 1",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..ab4e63e9699 --- /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." From 479c42aff319f05c3da9e7f5cfb2607de8d8afd5 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Thu, 20 Feb 2025 17:56:03 +0000 Subject: [PATCH 2/7] Add a more completed example in the issues_example CSV file. --- plugins/importexport/csv/examples/issues/issues_example.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/importexport/csv/examples/issues/issues_example.csv b/plugins/importexport/csv/examples/issues/issues_example.csv index 4b90442464b..8b5c9b596eb 100644 --- a/plugins/importexport/csv/examples/issues/issues_example.csv +++ b/plugins/importexport/csv/examples/issues/issues_example.csv @@ -1,2 +1,2 @@ journalPath,locale,articleTitle,articlePrefix,articleSubtitle,articleAbstract,articleFilepath,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,galleyFilenames,galleyLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage -leo,en,Title,PREF,articleSubtitle,Abstract,file.pdf,"GivenName,FamilyName,email@email.com,My Affiliation",keyword1,subject1,coverage,category1,10.5678/4jg7k43,coverImage.png,"Alt Text",galleyPdf.pdf,PDF,Articles,ART,"Issue 1, Vol. 1",1,1,2024,"Issue Description",2024-09-09,1,15 +leo,en,"Article Title",PREF,"Article Subtitle","Article Abstract",file.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","galleyPdf.pdf;galleyHtml.html","PDF;HTML",Articles,ART,"Issue Title",1,1,2024,"Issue Description",2024-09-09,1,15 From 04c06e017696b5e6c8887601a871492d8cf61c3b Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Tue, 25 Feb 2025 16:06:10 +0000 Subject: [PATCH 3/7] fix keywords and subjects processing --- .../csv/classes/processors/KeywordsProcessor.php | 6 +++--- .../csv/classes/processors/SubjectsProcessor.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/importexport/csv/classes/processors/KeywordsProcessor.php b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php index e0013bea836..2bd78be3f74 100644 --- a/plugins/importexport/csv/classes/processors/KeywordsProcessor.php +++ b/plugins/importexport/csv/classes/processors/KeywordsProcessor.php @@ -27,12 +27,12 @@ class KeywordsProcessor */ public static function process(object $data, int $publicationId): void { - $keywordsList = [$data->locale => array_map('trim', explode(';', $data->keywords))]; + $keywordsList = array_map('trim', explode(';', $data->keywords)); - if (count($keywordsList[$data->locale]) > 0) { + if (count($keywordsList) > 0) { Repo::controlledVocab()->insertBySymbolic( ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_KEYWORD, - $keywordsList[$data->locale], + [$data->locale => $keywordsList], Application::ASSOC_TYPE_PUBLICATION, $publicationId ); diff --git a/plugins/importexport/csv/classes/processors/SubjectsProcessor.php b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php index 7267a8f17c1..363582d5ebc 100644 --- a/plugins/importexport/csv/classes/processors/SubjectsProcessor.php +++ b/plugins/importexport/csv/classes/processors/SubjectsProcessor.php @@ -27,12 +27,12 @@ class SubjectsProcessor */ public static function process(object $data, int $publicationId): void { - $subjectsList = [$data->locale => array_map('trim', explode(';', $data->subjects))]; + $subjectsList = array_map('trim', explode(';', $data->subjects)); - if (count($subjectsList[$data->locale]) > 0) { + if (count($subjectsList) > 0) { Repo::controlledVocab()->insertBySymbolic( ControlledVocab::CONTROLLED_VOCAB_SUBMISSION_SUBJECT, - $subjectsList[$data->locale], + [$data->locale => $subjectsList], Application::ASSOC_TYPE_PUBLICATION, $publicationId ); From 932fb4735b204dee10ff9b6b79f11a782e4f2257 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Mon, 3 Mar 2025 14:47:01 +0000 Subject: [PATCH 4/7] update the galley filenames and descriptions so it is more concise to it's actual behavior --- plugins/importexport/csv/README.md | 20 ++++++------ .../csv/classes/commands/IssueCommand.php | 32 +++++++++---------- .../validations/InvalidRowValidations.php | 16 +++++----- .../validations/RequiredIssueHeaders.php | 8 ++--- .../csv/examples/issues/issues_example.csv | 2 +- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/plugins/importexport/csv/README.md b/plugins/importexport/csv/README.md index 461f6663b82..ad006816f58 100644 --- a/plugins/importexport/csv/README.md +++ b/plugins/importexport/csv/README.md @@ -57,12 +57,12 @@ php tools/importExport.php CSVImportExportPlugin users admin /path/to/csv/direct Complete field list (in order): ``` -journalPath,locale,articleTitle,articlePrefix,articleSubtitle,articleAbstract,articleFilepath,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,galleyFilenames,galleyLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage +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,articleFilepath,authors,issueTitle,issueVolume,issueNumber,issueYear,datePublished +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. @@ -71,13 +71,13 @@ journalPath,locale,articleTitle,articleAbstract,articleFilepath,authors,issueTit 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 `articleFilepath` column -- Galley files referenced in `galleyFilenames` column +- Article files referenced in `articleGalleyFilename` column +- Galley files referenced in `suppFilenames` column - Cover images referenced in `coverImageFilename` column For example, if your CSV contains: ``` -articleFilepath=article1.pdf,galleyFilenames=galleys1.pdf;galleys2.pdf,coverImageFilename=cover.png +articleGalleyFilename=article1.pdf,suppFilenames=galleys1.pdf;galleys2.pdf,coverImageFilename=cover.png ``` Your directory should contain: @@ -98,7 +98,7 @@ Field descriptions: - `articlePrefix`: Prefix for the article title - `articleSubtitle`: Subtitle of the article - `articleAbstract`: Article abstract -- `articleFilepath`: Path to the article's main file +- `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 (;) @@ -116,8 +116,8 @@ Field descriptions: - `doi`: Digital Object Identifier - `coverImageFilename`: Cover image file name - `coverImageAltText`: Alt text for cover image -- `galleyFilenames`: Names of galley files (semicolon-separated) -- `galleyLabels`: Labels for galleys (semicolon-separated). Must have the same number of items as `galleyFilenames` to ensure correct pairing between files and labels +- `suppFilenames`: Names of supplementary files (semicolon-separated) +- `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 @@ -171,6 +171,6 @@ For fields that accept multiple values: - Multiple authors must be separated by semicolons - Example: "John,Doe,john@email.com,University A;Jane,,jane@email.com,;Robert,Smith,," - For galleys: - - Both `galleyFilenames` and `galleyLabels` support multiple values + - 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 `galleyFilenames=article.pdf;article.html`, then `galleyLabels` must be something like `PDF;HTML` + - Example: if `suppFilenames=article.pdf;article.html`, then `suppLabels` must be something like `PDF;HTML` diff --git a/plugins/importexport/csv/classes/commands/IssueCommand.php b/plugins/importexport/csv/classes/commands/IssueCommand.php index 7730a768dae..c9dc9b3c442 100644 --- a/plugins/importexport/csv/classes/commands/IssueCommand.php +++ b/plugins/importexport/csv/classes/commands/IssueCommand.php @@ -135,17 +135,17 @@ public function run(): void $fieldsList = array_pad($fields, $this->expectedRowSize, null); - $reason = InvalidRowValidations::validateArticleFileIsValid($data->articleFilepath, $this->sourceDir); + $reason = InvalidRowValidations::validateArticleFileIsValid($data->articleGalleyFilename, $this->sourceDir); if (!is_null($reason)) { CSVFileHandler::processFailedRow($invalidCsvFile, $fields, $this->expectedRowSize, $reason, $this->failedRows); continue; } - if ($data->galleyFilenames) { + if ($data->suppFilenames) { $reason = InvalidRowValidations::validateArticleGalleys( - $data->galleyFilenames, - $data->galleyLabels, + $data->suppFilenames, + $data->suppLabels, $this->sourceDir ); @@ -219,8 +219,8 @@ public function run(): void // Copy Submission file. If an error occured, save this row as invalid, // delete the saved submission and continue the loop. - $articleFilePathId = $this->saveSubmissionFile( - $data->articleFilepath, + $articleGalleyFilenameId = $this->saveSubmissionFile( + $data->articleGalleyFilename, $journal->getId(), $submission->getId(), $invalidCsvFile, @@ -228,24 +228,24 @@ public function run(): void $fieldsList ); - if (is_null($articleFilePathId)) { + if (is_null($articleGalleyFilenameId)) { continue; } // // Array to store each galley ID to its respective galley file $galleyIds = []; - foreach (array_map('trim', explode(';', $data->galleyFilenames)) as $galleyFile) { - $galleyFileId = $this->saveSubmissionFile( - $galleyFile, + foreach (array_map('trim', explode(';', $data->suppFilenames)) as $suppFile) { + $suppFileId = $this->saveSubmissionFile( + $suppFile, $journal->getId(), $submission->getId(), $invalidCsvFile, - __('plugins.importexport.csv.errorWhileSavingSubmissionGalley', ['galley' => $galleyFile]), + __('plugins.importexport.csv.errorWhileSavingSubmissionGalley', ['galley' => $suppFile]), $fieldsList ); - if (is_null($galleyFileId)) { - $this->fileService->delete($articleFilePathId); + if (is_null($suppFileId)) { + $this->fileService->delete($articleGalleyFilenameId); foreach ($galleyIds as $galleyItem) { $this->fileService->delete($galleyItem['id']); @@ -254,21 +254,21 @@ public function run(): void continue; } - $galleyIds[] = ['file' => $galleyFile, 'id' => $galleyFileId]; + $galleyIds[] = ['file' => $suppFile, 'id' => $suppFileId]; } $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->articleFilepath}"; + $articleFileCompletePath = "{$this->sourceDir}/{$data->articleGalleyFilename}"; SubmissionFileProcessor::process( $data->locale, $this->user->getId(), $submission->getId(), $articleFileCompletePath, $genreId, - $articleFilePathId + $articleGalleyFilenameId ); // Now, process the submission file for all article galleys diff --git a/plugins/importexport/csv/classes/validations/InvalidRowValidations.php b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php index 7cd2e97457b..f30652a4b09 100644 --- a/plugins/importexport/csv/classes/validations/InvalidRowValidations.php +++ b/plugins/importexport/csv/classes/validations/InvalidRowValidations.php @@ -85,19 +85,19 @@ public static function validateCoverImageIsValid(string $coverImageFilename, str * Perform all necessary validations for article galleys. Returns the reason if an error occurred, * or null if everything is correct. */ - public static function validateArticleGalleys(string $galleyFilenames, string $galleyLabels, string $sourceDir): ?string + public static function validateArticleGalleys(string $suppFilenames, string $suppLabels, string $sourceDir): ?string { - $galleyFilenamesArray = explode(';', $galleyFilenames); - $galleyLabelsArray = explode(';', $galleyLabels); + $suppFilenamesArray = explode(';', $suppFilenames); + $suppLabelsArray = explode(';', $suppLabels); - if (count($galleyFilenamesArray) !== count($galleyLabelsArray)) { + if (count($suppFilenamesArray) !== count($suppLabelsArray)) { return __('plugins.importexport.csv.invalidNumberOfLabelsAndGalleys'); } - foreach ($galleyFilenamesArray as $galleyFilename) { - $galleyPath = "{$sourceDir}/{$galleyFilename}"; - if (!is_readable($galleyPath)) { - return __('plugins.importexport.csv.invalidGalleyFile', ['filename' => $galleyFilename]); + foreach ($suppFilenamesArray as $suppFilename) { + $suppPath = "{$sourceDir}/{$suppFilename}"; + if (!is_readable($suppPath)) { + return __('plugins.importexport.csv.invalidGalleyFile', ['filename' => $suppFilename]); } } diff --git a/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php index 55ecb34e140..59b6c4ba1e3 100644 --- a/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php +++ b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php @@ -25,7 +25,7 @@ class RequiredIssueHeaders 'articlePrefix', 'articleSubtitle', 'articleAbstract', - 'articleFilepath', + 'articleGalleyFilename', 'authors', 'keywords', 'subjects', @@ -34,8 +34,8 @@ class RequiredIssueHeaders 'doi', 'coverImageFilename', 'coverImageAltText', - 'galleyFilenames', - 'galleyLabels', + 'suppFilenames', + 'suppLabels', 'sectionTitle', 'sectionAbbrev', 'issueTitle', @@ -53,7 +53,7 @@ class RequiredIssueHeaders 'locale', 'articleTitle', 'articleAbstract', - 'articleFilepath', + 'articleGalleyFilename', 'authors', 'issueTitle', 'issueVolume', diff --git a/plugins/importexport/csv/examples/issues/issues_example.csv b/plugins/importexport/csv/examples/issues/issues_example.csv index 8b5c9b596eb..62b302f6ea3 100644 --- a/plugins/importexport/csv/examples/issues/issues_example.csv +++ b/plugins/importexport/csv/examples/issues/issues_example.csv @@ -1,2 +1,2 @@ -journalPath,locale,articleTitle,articlePrefix,articleSubtitle,articleAbstract,articleFilepath,authors,keywords,subjects,coverage,categories,doi,coverImageFilename,coverImageAltText,galleyFilenames,galleyLabels,genreName,sectionTitle,sectionAbbrev,issueTitle,issueVolume,issueNumber,issueYear,issueDescription,datePublished,startPage,endPage +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",file.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","galleyPdf.pdf;galleyHtml.html","PDF;HTML",Articles,ART,"Issue Title",1,1,2024,"Issue Description",2024-09-09,1,15 From 15b64e3dbf11ed76c29f8ad1b279a01e14f2f5a2 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Mon, 3 Mar 2025 15:07:46 +0000 Subject: [PATCH 5/7] Insert genreName content into CSV file --- plugins/importexport/csv/examples/issues/issues_example.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/importexport/csv/examples/issues/issues_example.csv b/plugins/importexport/csv/examples/issues/issues_example.csv index 62b302f6ea3..cc1d29e3956 100644 --- a/plugins/importexport/csv/examples/issues/issues_example.csv +++ b/plugins/importexport/csv/examples/issues/issues_example.csv @@ -1,2 +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",file.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","galleyPdf.pdf;galleyHtml.html","PDF;HTML",Articles,ART,"Issue Title",1,1,2024,"Issue Description",2024-09-09,1,15 +leo,en,"Article Title",PREF,"Article Subtitle","Article Abstract",file.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","galleyPdf.pdf;galleyHtml.html","PDF;HTML","Article Text",Articles,ART,"Issue Title",1,1,2024,"Issue Description",2024-09-09,1,15 From 4f7b956970a5a850221ddbd38a809eb827cbf5ff Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Fri, 7 Mar 2025 13:05:53 +0000 Subject: [PATCH 6/7] Update CSV with correct example file names. Add more explains on README.md. Fix issue headers on RequiredIssueHeaders file --- plugins/importexport/csv/README.md | 10 +++++----- .../csv/classes/validations/RequiredIssueHeaders.php | 1 + .../csv/examples/issues/issues_example.csv | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/importexport/csv/README.md b/plugins/importexport/csv/README.md index ad006816f58..60916c9ddba 100644 --- a/plugins/importexport/csv/README.md +++ b/plugins/importexport/csv/README.md @@ -77,16 +77,16 @@ All files referenced in the CSV must be placed in the same directory as your CSV For example, if your CSV contains: ``` -articleGalleyFilename=article1.pdf,suppFilenames=galleys1.pdf;galleys2.pdf,coverImageFilename=cover.png +articleGalleyFilename=articleGalley.pdf,suppFilenames=suppFile1.pdf;suppFile2.pdf,coverImageFilename=cover.png ``` Your directory should contain: ``` /your/import/directory/ ├── issues.csv - ├── article1.pdf - ├── galleys1.pdf - ├── galleys2.pdf + ├── articleGalley.pdf + ├── suppFile1.pdf + ├── suppFile2.pdf └── cover.png ``` @@ -116,7 +116,7 @@ Field descriptions: - `doi`: Digital Object Identifier - `coverImageFilename`: Cover image file name - `coverImageAltText`: Alt text for cover image -- `suppFilenames`: Names of supplementary files (semicolon-separated) +- `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 diff --git a/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php index 59b6c4ba1e3..9f192fea856 100644 --- a/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php +++ b/plugins/importexport/csv/classes/validations/RequiredIssueHeaders.php @@ -36,6 +36,7 @@ class RequiredIssueHeaders 'coverImageAltText', 'suppFilenames', 'suppLabels', + 'genreName', 'sectionTitle', 'sectionAbbrev', 'issueTitle', diff --git a/plugins/importexport/csv/examples/issues/issues_example.csv b/plugins/importexport/csv/examples/issues/issues_example.csv index cc1d29e3956..7098ecb45d1 100644 --- a/plugins/importexport/csv/examples/issues/issues_example.csv +++ b/plugins/importexport/csv/examples/issues/issues_example.csv @@ -1,2 +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",file.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","galleyPdf.pdf;galleyHtml.html","PDF;HTML","Article Text",Articles,ART,"Issue Title",1,1,2024,"Issue Description",2024-09-09,1,15 +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 From 1f26f8a167e0862b99a522e7dc7eb2807219961a Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Fri, 7 Mar 2025 13:13:55 +0000 Subject: [PATCH 7/7] fix README.md examples at the end --- plugins/importexport/csv/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/importexport/csv/README.md b/plugins/importexport/csv/README.md index 60916c9ddba..93e282f1ef3 100644 --- a/plugins/importexport/csv/README.md +++ b/plugins/importexport/csv/README.md @@ -173,4 +173,4 @@ For fields that accept multiple values: - 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=article.pdf;article.html`, then `suppLabels` must be something like `PDF;HTML` + - Example: if `suppFilenames=suppFile1.pdf;suppFile2.pdf`, then `suppLabels` must be something like `PDF;PDF`