diff --git a/api/v1/jats/PKPJatsController.php b/api/v1/jats/PKPJatsController.php new file mode 100644 index 00000000000..d7fdf679bea --- /dev/null +++ b/api/v1/jats/PKPJatsController.php @@ -0,0 +1,213 @@ +group(function () { + + Route::get('', $this->get(...)) + ->name('publication.jats.get'); + + Route::post('', $this->add(...)) + ->name('publication.jats.add'); + + Route::delete('', $this->delete(...)) + ->name('publication.jats.delete'); + + })->whereNumber(['submissionId', 'publicationId']); + } + + /** + * @copydoc \PKP\core\PKPBaseController::authorize() + */ + public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool + { + $illuminateRequest = $args[0]; /** @var \Illuminate\Http\Request $illuminateRequest */ + $actionName = static::getRouteActionName($illuminateRequest); + + $this->addPolicy(new UserRolesRequiredPolicy($request), true); + + $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + + $this->addPolicy(new PublicationWritePolicy($request, $args, $roleAssignments)); + + if ($actionName === 'add') { + $params = $illuminateRequest->input(); + $fileStage = isset($params['fileStage']) ? (int) $params['fileStage'] : SubmissionFile::SUBMISSION_FILE_JATS; + $this->addPolicy( + new SubmissionFileStageAccessPolicy( + $fileStage, + SubmissionFileAccessPolicy::SUBMISSION_FILE_ACCESS_MODIFY, + 'api.submissionFiles.403.unauthorizedFileStageIdWrite' + ) + ); + } + + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Get JATS XML Files + */ + public function get(Request $illuminateRequest): JsonResponse + { + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $publication = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_PUBLICATION); + + if (!$publication) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $context = Application::get()->getRequest()->getContext(); + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getEnabledByContextId($context->getId()); + + $jatsFile = Repo::jats() + ->getJatsFile($publication->getId(), $submission->getId(), $genres->toArray()); + + $jatsFilesProp = Repo::jats() + ->summarize($jatsFile); + + return response()->json($jatsFilesProp, Response::HTTP_OK); + } + + /** + * Add a JATS XML Submission File to a publication + */ + public function add(Request $illuminateRequest): JsonResponse + { + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $publication = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_PUBLICATION); + + if (empty($_FILES)) { + return response()->json([ + 'error' => __('api.files.400.noUpload'), + ], Response::HTTP_BAD_REQUEST); + } + + if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) { + return $this->getUploadErrorResponse($_FILES['file']['error']); + } + + $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_SUBMISSION_FILE, $illuminateRequest->input()); + + Repo::jats() + ->addJatsFile( + $_FILES['file']['tmp_name'], + $_FILES['file']['name'], + $publication->getId(), + $submission->getId(), + SubmissionFile::SUBMISSION_FILE_JATS, + $params); + + $context = Application::get()->getRequest()->getContext(); + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getEnabledByContextId($context->getId()); + + $jatsFile = Repo::jats() + ->getJatsFile($publication->getId(), $submission->getId(), $genres->toArray()); + + $jatsFilesProp = Repo::jats() + ->summarize($jatsFile); + + return response()->json($jatsFilesProp, Response::HTTP_OK); + } + + /** + * Delete the publication's JATS Submission file + */ + public function delete(Request $illuminateRequest): JsonResponse + { + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $publication = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_PUBLICATION); + + $context = Application::get()->getRequest()->getContext(); + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getEnabledByContextId($context->getId()); + + $jatsFile = Repo::jats() + ->getJatsFile($publication->getId(), $submission->getId(), $genres->toArray()); + + if (!$jatsFile->submissionFile) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + Repo::submissionFile() + ->delete($jatsFile->submissionFile); + + $jatsFile = Repo::jats() + ->getJatsFile($publication->getId(), $submission->getId(), $genres->toArray()); + + $jatsFilesProp = Repo::jats() + ->summarize($jatsFile); + + return response()->json($jatsFilesProp, Response::HTTP_OK); + } +} diff --git a/api/v1/submissions/PKPSubmissionController.php b/api/v1/submissions/PKPSubmissionController.php index 09ef8c0b9de..2ee104139e6 100644 --- a/api/v1/submissions/PKPSubmissionController.php +++ b/api/v1/submissions/PKPSubmissionController.php @@ -292,9 +292,6 @@ public function getGroupRoutes(): void ]); } - - - /** * @copydoc \PKP\core\PKPBaseController::authorize() */ @@ -838,6 +835,7 @@ public function getParticipants(Request $illuminateRequest): JsonResponse $request = Application::get()->getRequest(); $context = $request->getContext(); $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $args = $illuminateRequest->input(); $stageId = $args['stageId'] ?? null; if (!$submission || $submission->getData('contextId') !== $context->getId()) { @@ -1591,8 +1589,11 @@ public function saveContributorsOrder(Request $illuminateRequest): JsonResponse ->filterByPublicationIds([$publication->getId()]) ->getMany(); + $authorsArray = Repo::author()->getSchemaMap()->summarizeMany($authors)->toArray(); + $indexedArray = array_values($authorsArray); + return response()->json( - Repo::author()->getSchemaMap()->summarizeMany($authors), + $indexedArray, Response::HTTP_OK ); } diff --git a/classes/components/PublicationSectionJats.php b/classes/components/PublicationSectionJats.php new file mode 100644 index 00000000000..5384f68a236 --- /dev/null +++ b/classes/components/PublicationSectionJats.php @@ -0,0 +1,74 @@ + $this->title, + 'id' => $this->id, + 'canEditPublication' => $this->canEditPublication, + 'publicationApiUrlFormat' => $this->getPublicationUrlFormat(), + 'fileStage' => SubmissionFile::SUBMISSION_FILE_JATS, + 'downloadDefaultJatsFileName' => Repo::jats()->getDefaultJatsFileName($this->publication->getId()), + ] + ); + + return $config; + } + + /** + * Get an example of the url to a publication's API endpoint, + * with a placeholder instead of the publication id, eg: + * + * http://example.org/api/v1/submissions/1/publications/{$publicationId} + */ + protected function getPublicationUrlFormat(): string + { + return Application::get()->getRequest()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_API, + $this->context->getPath(), + 'submissions/' . $this->submission->getId() . '/publications/{$publicationId}/jats' + ); + } +} diff --git a/classes/jats/JatsFile.php b/classes/jats/JatsFile.php new file mode 100644 index 00000000000..461af050812 --- /dev/null +++ b/classes/jats/JatsFile.php @@ -0,0 +1,53 @@ +isDefaultContent = false; + + $this->jatsContent = Repo::submissionFile() + ->getSubmissionFileContent($submissionFile); + } else { + $this->jatsContent = Repo::jats() + ->createDefaultJatsContent($publicationId, $submissionId); + } + } catch (UnableToCreateFileContentException | UnableToCreateJATSContentException $e) { + $this->loadingContentError = $e->getMessage(); + } + + } +} diff --git a/classes/jats/Repository.php b/classes/jats/Repository.php new file mode 100644 index 00000000000..04bbf561617 --- /dev/null +++ b/classes/jats/Repository.php @@ -0,0 +1,239 @@ +isDefaultContent) { + $fileProps = Repo::submissionFile() + ->getSchemaMap() + ->summarize($jatsFile->submissionFile, $jatsFile->genres); + } + + if ($jatsFile->jatsContent) { + $fileProps['jatsContent'] = $jatsFile->jatsContent; + } + + $fileProps['isDefaultContent'] = $jatsFile->isDefaultContent; + + if ($jatsFile->loadingContentError) { + $fileProps['loadingContentError'] = $jatsFile->loadingContentError; + } + + return $fileProps; + } + + /** + * Returns the SubmissionFile, if any, that corresponds to the JATS contents of the given submission/publication + */ + public function getJatsFile(int $publicationId, ?int $submissionId = null, array $genres): ?JatsFile + { + $submissionFileQuery = Repo::submissionFile() + ->getCollector() + ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_JATS]) + ->filterByAssoc(Application::ASSOC_TYPE_PUBLICATION, [$publicationId]); + + if ($submissionId) { + $submissionFileQuery = $submissionFileQuery->filterBySubmissionIds([$submissionId]); + } + + $submissionFile = $submissionFileQuery + ->getMany() + ->first(); + + return new JatsFile( + $publicationId, + $submissionId, + $submissionFile, + $genres + ); + } + + /** + * Returns the name of the file that will contain the default JATS content + */ + public function getDefaultJatsFileName(int $publicationId): string + { + return 'jats-' . $publicationId . '-' . date('Ymd-His') . '.xml'; + } + + /** + * Creates the default JATS XML Content from the given submission/publication metadata + * + * @throws \PKP\jats\exceptions\UnableToCreateJATSContentException If the default JATS creation fails + */ + public function createDefaultJatsContent(int $publicationId, ?int $submissionId = null): string + { + $publication = Repo::publication()->get($publicationId, $submissionId); + $submission = Repo::submission()->get($publication->getData('submissionId')); + + $context = Services::get('context')->get($submission->getData('contextId')); + $section = Repo::section()->get($submission->getSectionId()); + + $issue = null; + if ($publication->getData('issueId')) { + $issue = Repo::issue()->get($publication->getData('issueId')); + } + + try { + $exportXml = $this->convertSubmissionToJatsXml($submission, $context, $section, $issue, $publication, Application::get()->getRequest()); + } catch (Throwable $e) { + throw new UnableToCreateJATSContentException($e); + } + + return $exportXml; + } + + /** + * Base function that will add a new JATS file + */ + public function addJatsFile( + string $fileTmpName, + string $fileName, + int $publicationId, + ?int $submissionId = null, + int $type = SubmissionFile::SUBMISSION_FILE_JATS, + array $params = [] + ): JatsFile + { + $publication = Repo::publication()->get($publicationId, $submissionId); + $submission = Repo::submission()->get($publication->getData('submissionId')); + + $context = Application::get()->getRequest()->getContext(); + $user = Application::get()->getRequest()->getUser(); + + // If no genre has been set and there is only one genre possible, set it automatically + /** @var GenreDAO */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getEnabledByContextId($context->getId()); + + $existingJatsFile = $this->getJatsFile($publicationId, $submissionId, $genres->toArray()); + if (!$existingJatsFile->isDefaultContent) { + throw new Exception('A JATS file already exists'); + } + + $fileManager = new FileManager(); + $extension = $fileManager->parseFileExtension($fileName); + + $submissionDir = Repo::submissionFile() + ->getSubmissionDir( + $submission->getData('contextId'), + $submission->getId() + ); + + $fileId = Services::get('file')->add( + $fileTmpName, + $submissionDir . '/' . uniqid() . '.' . $extension + ); + + $params['fileId'] = $fileId; + $params['submissionId'] = $submission->getId(); + $params['uploaderUserId'] = $user->getId(); + $params['fileStage'] = $type; + + $primaryLocale = $context->getPrimaryLocale(); + $allowedLocales = $context->getData('supportedSubmissionLocales'); + + $params['name'] = null; + $params['name'][$primaryLocale] = $fileName; + + if (empty($params['genreId'])) { + + [$firstGenre, $secondGenre] = [$genres->next(), $genres->next()]; + if ($firstGenre && !$secondGenre) { + $params['genreId'] = $firstGenre->getId(); + } + } + + $params['assocType'] = Application::ASSOC_TYPE_PUBLICATION; + $params['assocId'] = $publication->getId(); + + $errors = Repo::submissionFile() + ->validate( + null, + $params, + $allowedLocales, + $primaryLocale + ); + + if (!empty($errors)) { + Services::get('file')->delete($fileId); + throw new Exception(''. implode(', ', $errors)); + } + + $submissionFile = Repo::submissionFile() + ->newDataObject($params); + + $submissionFileId = Repo::submissionFile() + ->add($submissionFile); + + $jatsFile = Repo::jats() + ->getJatsFile($publication->getId(), $submission->getId(), $genres->toArray()); + + return $jatsFile; + } + + /** + * Given a submission and a publication this function returns the JATS XML contents provided by the + * submission/publication metadata + * + * @throws \PKP\jats\exceptions\UnableToCreateJATSContentException If the default JATS creation fails + */ + protected function convertSubmissionToJatsXml($submission, $journal, $section, $issue, $publication, $request): string + { + if (!class_exists(\APP\plugins\generic\jatsTemplate\classes\Article::class)) { + throw new UnableToCreateJATSContentException(); + } + + $articleJats = new Article(); + + $articleJats->preserveWhiteSpace = false; + $articleJats->formatOutput = true; + + $articleJats->convertSubmission($submission, $journal, $section, $issue, $publication, $request); + + $formattedXml = $articleJats->saveXML(); + + return $formattedXml; + } + + /** + * Get all valid file stages + * + * Valid file stages should be passed through + * the hook SubmissionFile::fileStages. + * @return array + */ + public function getFileStages(): array { + return [SubmissionFile::SUBMISSION_FILE_JATS]; + } +} diff --git a/classes/jats/exceptions/UnableToCreateJATSContentException.php b/classes/jats/exceptions/UnableToCreateJATSContentException.php new file mode 100644 index 00000000000..cba4f67d1e9 --- /dev/null +++ b/classes/jats/exceptions/UnableToCreateJATSContentException.php @@ -0,0 +1,25 @@ +importCitations($newPublication->getId(), $newPublication->getData('citationsRaw')); } + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getEnabledByContextId($context->getId()); + + $jatsFile = Repo::jats() + ->getJatsFile($publication->getId(), null, $genres->toArray()); + + if (!$jatsFile->isDefaultContent) { + Repo::submissionFile() + ->versionSubmissionFile($jatsFile->submissionFile, $newPublication); + } + $newPublication = Repo::publication()->get($newPublication->getId()); Hook::call('Publication::version', [&$newPublication, $publication]); diff --git a/classes/submissionFile/Repository.php b/classes/submissionFile/Repository.php index 640a72fc938..a66a9b5c90b 100644 --- a/classes/submissionFile/Repository.php +++ b/classes/submissionFile/Repository.php @@ -19,13 +19,16 @@ use APP\facades\Repo; use APP\notification\Notification; use APP\notification\NotificationManager; +use APP\publication\Publication; use Exception; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +use PKP\config\Config; use PKP\core\Core; use PKP\core\PKPApplication; use PKP\db\DAORegistry; +use PKP\file\FileManager; use PKP\log\event\SubmissionFileEventLogEntry; use PKP\log\SubmissionEmailLogDAO; use PKP\log\SubmissionEmailLogEntry; @@ -40,6 +43,7 @@ use PKP\services\PKPSchemaService; use PKP\stageAssignment\StageAssignmentDAO; use PKP\submission\reviewRound\ReviewRoundDAO; +use PKP\submissionFile\exceptions\UnableToCreateFileContentException; use PKP\submissionFile\maps\Schema; use PKP\validation\ValidatorFactory; @@ -662,7 +666,8 @@ public function getWorkflowStageId(SubmissionFile $submissionFile): ?int if ( $fileStage === SubmissionFile::SUBMISSION_FILE_PROOF || - $fileStage === SubmissionFile::SUBMISSION_FILE_PRODUCTION_READY + $fileStage === SubmissionFile::SUBMISSION_FILE_PRODUCTION_READY || + $fileStage === SubmissionFile::SUBMISSION_FILE_JATS ) { return WORKFLOW_STAGE_ID_PRODUCTION; } @@ -843,4 +848,64 @@ protected function getSubmissionFileLogData(SubmissionFile $submissionFile): arr 'username' => $user?->getUsername(), ]; } + + /** + * Can be used to copy a SubmissionFile to another SubmissionFile along with the corresponding file + */ + public function versionSubmissionFile( + SubmissionFile $submissionFile, + Publication $newPublication + ): SubmissionFile + { + $newSubmissionFile = clone $submissionFile; + + $oldFileId = $submissionFile->getData('fileId'); + + $oldFile = Services::get('file')->get($oldFileId); + + $submission = Repo::submission()->get($newPublication->getData('submissionId')); + + $fileManager = new FileManager(); + $extension = $fileManager->parseFileExtension($oldFile->path); + + $submissionDir = Repo::submissionFile() + ->getSubmissionDir( + $submission->getData('contextId'), + $newPublication->getData('submissionId') + ); + + $newFileId = Services::get('file')->add( + Config::getVar('files', 'files_dir') . '/' . $oldFile->path, + $submissionDir . '/' . uniqid() . '.' . $extension + ); + + $newSubmissionFile->setData('id', null); + $newSubmissionFile->setData('assocId', $newPublication->getId()); + $newSubmissionFile->setData('fileId', $newFileId); + + $submissionFileId = Repo::submissionFile() + ->add($newSubmissionFile); + + $submissionFile = Repo::submissionFile() + ->get($submissionFileId); + + return $submissionFile; + } + + /** + * Returns jatsContent for Submission files that correspond to the content of the file + * + * @throws \PKP\submissionFile\exceptions\UnableToCreateFileContentException If the default JATS creation fails + */ + public function getSubmissionFileContent(SubmissionFile $submissionFile): string | false + { + $fileName = Config::getVar('files', 'files_dir') . '/' . $submissionFile->getData('path') .''; + $retValue = file_get_contents($fileName); + + if ($retValue === false) { + throw new UnableToCreateFileContentException($fileName); + } + + return $retValue; + } } diff --git a/classes/submissionFile/SubmissionFile.php b/classes/submissionFile/SubmissionFile.php index bdd41e612d9..5dc94715124 100644 --- a/classes/submissionFile/SubmissionFile.php +++ b/classes/submissionFile/SubmissionFile.php @@ -38,6 +38,7 @@ class SubmissionFile extends \PKP\core\DataObject public const SUBMISSION_FILE_QUERY = 18; public const SUBMISSION_FILE_INTERNAL_REVIEW_FILE = 19; public const SUBMISSION_FILE_INTERNAL_REVIEW_REVISION = 20; + public const SUBMISSION_FILE_JATS = 21; public const INTERNAL_REVIEW_STAGES = [ SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE, diff --git a/classes/submissionFile/exceptions/UnableToCreateFileContentException.php b/classes/submissionFile/exceptions/UnableToCreateFileContentException.php new file mode 100644 index 00000000000..48cd15254b7 --- /dev/null +++ b/classes/submissionFile/exceptions/UnableToCreateFileContentException.php @@ -0,0 +1,25 @@ + $fileName]), null, $innerException); + } +} diff --git a/js/load.js b/js/load.js index 85113b16e00..c325d331412 100644 --- a/js/load.js +++ b/js/load.js @@ -92,6 +92,8 @@ import FieldText from '@/components/Form/fields/FieldText.vue'; import FieldTextarea from '@/components/Form/fields/FieldTextarea.vue'; import FieldUpload from '@/components/Form/fields/FieldUpload.vue'; import FieldUploadImage from '@/components/Form/fields/FieldUploadImage.vue'; +import VueHighlightJS from 'vue3-highlightjs'; +import 'highlight.js/styles/default.css'; // Panel components from UI Library import ListPanel from '@/components/ListPanel/ListPanel.vue'; @@ -233,6 +235,8 @@ function pkpCreateVueApp(createAppArgs) { vueApp.component(componentName, allGlobalComponents[componentName]); }); + vueApp.use(VueHighlightJS); + return vueApp; } diff --git a/locale/en/submission.po b/locale/en/submission.po index 7b3fca6a6ef..175165e2fde 100644 --- a/locale/en/submission.po +++ b/locale/en/submission.po @@ -2395,3 +2395,27 @@ msgstr "Pending" msgid "submission.dashboard.view.reviewAssignments.archived" msgstr "Completed / Declined" + +msgid "publication.jats" +msgstr "JATS XML" + +msgid "publication.jats.confirmDeleteFileTitle" +msgstr "Confirm deleting JATS XML" + +msgid "publication.jats.confirmDeleteFileMessage" +msgstr "You are about to remove the existing JATS XML File from this publication. Are you sure?" + +msgid "publication.jats.confirmDeleteFileButton" +msgstr "Delete JATS File" + +msgid "publication.jats.autoCreatedMessage" +msgstr "This JATS file is generated automatically by the submission metadata" + +msgid "publication.jats.lastModified" +msgstr "Last Modification at {$modificationDate} by {$username}" + +msgid "publication.jats.defaultContentCreationError" +msgstr "An error occured when trying to create the default JATS content. Please make sure that the JatsTemplate plugin is installed." + +msgid "submission.files.content.error" +msgstr "The content of the submission file '{$fileName}' could not be retrieved." diff --git a/pages/workflow/PKPWorkflowHandler.php b/pages/workflow/PKPWorkflowHandler.php index c5a291340ab..59c6ca22096 100644 --- a/pages/workflow/PKPWorkflowHandler.php +++ b/pages/workflow/PKPWorkflowHandler.php @@ -32,6 +32,7 @@ use PKP\components\forms\publication\PKPPublicationLicenseForm; use PKP\components\forms\publication\TitleAbstractForm; use PKP\components\listPanels\ContributorsListPanel; +use PKP\components\PublicationSectionJats; use PKP\context\Context; use PKP\core\JSONMessage; use PKP\core\PKPApplication; @@ -850,6 +851,20 @@ protected function getContributorsListPanel(Submission $submission, Context $con ); } + /** + * Get the contributor list panel + */ + protected function getJatsPanel(Submission $submission, Context $context, bool $canEditPublication, Publication $publication): PublicationSectionJats + { + return new PublicationSectionJats( + 'jats', + __('publication.jats'), + $submission, + $context, + $canEditPublication, + $publication + ); + } // // Abstract protected methods. diff --git a/schemas/submissionFile.json b/schemas/submissionFile.json index 12c1fe4ce2b..9ace44f835b 100644 --- a/schemas/submissionFile.json +++ b/schemas/submissionFile.json @@ -27,7 +27,7 @@ "apiSummary": true, "description": "Used with `assocId` to associate this file with an object such as a galley. One of the following constants: `ASSOC_TYPE_SUBMISSION_FILE` (dependent files), `ASSOC_TYPE_REVIEW_ASSIGNMENT` (files uploaded by a reviewer), `ASSOC_TYPE_NOTE` (files uploaded with a discussion), `ASSOC_TYPE_REPRESENTATION` (files uploaded to a galley or publication format), `ASSOC_TYPE_REVIEW_ROUND` (review files and revisions for a particular review round).", "validation": [ - "in:515,517,520,521,523" + "in:515,517,520,521,523,1048588" ] }, "caption": { @@ -118,7 +118,7 @@ "type": "integer", "apiSummary": true, "validation": [ - "in:2,3,4,5,6,7,8,9,10,11,13,15,17,18" + "in:2,3,4,5,6,7,8,9,10,11,13,15,17,18,19,20,21" ] }, "genreId": {