aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--app/controllers/file.php7
-rw-r--r--app/controllers/files_dashboard/helpers.php8
-rw-r--r--app/views/file/annotate_pdf.php25
-rw-r--r--lib/classes/JsonApi/RouteMap.php3
-rw-r--r--lib/classes/JsonApi/Routes/Files/Authority.php8
-rw-r--r--lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php44
-rw-r--r--lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php35
-rw-r--r--lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php1
-rw-r--r--lib/filesystem/StandardFile.php15
-rw-r--r--lib/models/FileRef.php101
-rw-r--r--package-lock.json267
-rw-r--r--package.json1
-rw-r--r--resources/assets/stylesheets/scss/buttons.scss4
-rw-r--r--resources/studip.d.ts7
-rw-r--r--resources/vue/apps/PdfAnnotator.vue78
-rw-r--r--resources/vue/components/PdfJsViewer.vue107
-rw-r--r--webpack.common.js4
18 files changed, 712 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index db28479..07da848 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,7 @@ public/.htaccess
public/.rnd
public/assets/javascripts/*.js
public/assets/javascripts/*.js.map
+public/assets/javascripts/pdfjs
public/assets/stylesheets/*.css
public/assets/stylesheets/*.css.map
public/pictures/banner/*.gif
diff --git a/app/controllers/file.php b/app/controllers/file.php
index e0cfa44..1dbbf96 100644
--- a/app/controllers/file.php
+++ b/app/controllers/file.php
@@ -1578,6 +1578,13 @@ class FileController extends AuthenticatedController
}
}
+ /**
+ * Display the data-dialog UI to annotate the given fileref in PDF.js (for grading homework).
+ */
+ public function annotate_pdf_action($file_ref_id) {
+ $this->file_ref = FileRef::find($file_ref_id)->toRawArray();
+ $this->userFullname = User::findCurrent()->getFullName();
+ }
protected function loadFiles($param = 'files', $plugin = null, $with_blob = false)
{
diff --git a/app/controllers/files_dashboard/helpers.php b/app/controllers/files_dashboard/helpers.php
index d86fc9f..1ec673d 100644
--- a/app/controllers/files_dashboard/helpers.php
+++ b/app/controllers/files_dashboard/helpers.php
@@ -44,6 +44,14 @@ trait Helpers
Icon::create('edit'),
['data-dialog' => '']
);
+ if ('application/pdf' === $fileRef->file->mime_type) {
+ $actionMenu->addLink(
+ URLHelper::getURL('dispatch.php/file/annotate_pdf/' . $fileRef->id),
+ _('PDF-Werkzeuge'),
+ Icon::create('comment'),
+ ['data-dialog' => 'size=big']
+ );
+ }
$actionMenu->addLink(
URLHelper::getURL('dispatch.php/file/update/'.$fileRef->id),
_('Datei aktualisieren'),
diff --git a/app/views/file/annotate_pdf.php b/app/views/file/annotate_pdf.php
new file mode 100644
index 0000000..a732a8f
--- /dev/null
+++ b/app/views/file/annotate_pdf.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * @var string $file_ref_id
+ * @var FileRef $file_ref
+ * @var string $userFullname
+ */
+?>
+<?= Studip\VueApp::create('PdfAnnotator')->withProps([
+ 'file-ref' => $file_ref,
+ 'user-fullname' => $userFullname,
+ 'class' => 'annotate-pdf-root'
+]) ?>
+<footer data-dialog-button>
+ <?= Studip\Button::createAccept(
+ _('Speichern'),
+ 'save',
+ [
+ 'onclick' => 'STUDIP.eventBus.emit("files:save-annotated-pdf")',
+ ]
+ ); ?>
+ <?= Studip\LinkButton::createCancel(
+ _('Abbrechen'),
+ ['data-dialog' => 'close']
+ ); ?>
+</footer>
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 6b2f429..86c6d92 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -636,6 +636,8 @@ class RouteMap
$group->get('/file-refs/{id}/content', Routes\Files\FileRefsContentShow::class);
$group->post('/file-refs/{id}/content', Routes\Files\FileRefsContentUpdate::class);
+ $group->post('/file-refs/{id}/annotations', Routes\Files\FileRefsAnnotationCreate::class);
+
$group->get('/folders/{id}', Routes\Files\FoldersShow::class);
$group->patch('/folders/{id}', Routes\Files\FoldersUpdate::class);
$group->delete('/folders/{id}', Routes\Files\FoldersDelete::class);
@@ -651,6 +653,7 @@ class RouteMap
$group->get('/files/{id}', Routes\Files\FilesShow::class);
$group->get('/files/{id}/file-refs', Routes\Files\FileRefsOfFilesShow::class);
+ $group->get('/files/{id}/annotations', Routes\Files\FilesAnnotationsIndex::class);
$this->addRelationship($group, '/files/{id}/relationships/file-refs', Routes\Files\Rel\FileRefsOfFile::class);
}
diff --git a/lib/classes/JsonApi/Routes/Files/Authority.php b/lib/classes/JsonApi/Routes/Files/Authority.php
index 8f25bb6..f96475d 100644
--- a/lib/classes/JsonApi/Routes/Files/Authority.php
+++ b/lib/classes/JsonApi/Routes/Files/Authority.php
@@ -139,4 +139,12 @@ class Authority
{
return self::canCreateFileRefsInFolder($user, $destinationFolder) && self::canShowFolder($user, $sourceFolder);
}
+
+ public static function canAnnotateFileRef(User $user, \FileRef $fileRef)
+ {
+ $range = $fileRef->getRangeCourseId();
+ return static::canCreateFileRefsInFolder($user, $fileRef->folder->getTypedFolder())
+ && $GLOBALS['perm']->have_studip_perm('tutor', $fileRef->getRangeCourseId(), $user->id);
+ }
+
}
diff --git a/lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php b/lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php
new file mode 100644
index 0000000..78c0f3e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Files/FileRefsAnnotationCreate.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace JsonApi\Routes\Files;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Errors\UnprocessableEntityException;
+use JsonApi\NonJsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+class FileRefsAnnotationCreate extends NonJsonApiController
+{
+ use RoutesHelperTrait;
+
+ public function invoke(Request $request, Response $response, array $args): Response
+ {
+ $user = $this->getUser($request);
+
+ $originalFileRef = \FileRef::find($args['id']);
+ if (!$originalFileRef) {
+ throw new RecordNotFoundException();
+ }
+
+ if ('application/pdf' !== $originalFileRef->file->mime_type) {
+ throw new UnprocessableEntityException();
+ }
+
+ if (!Authority::canAnnotateFileRef($user, $originalFileRef)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $folder = $originalFileRef->folder->getTypedFolder();
+ $fileRef = $this->handleUpload($request, $folder);
+ $fileRef->content_terms_of_use_id = $originalFileRef->content_terms_of_use_id;
+ $fileRef->folder_id = $originalFileRef->folder_id;
+
+ // Store annotated file, updating its metadata.
+ $fileRef->setAnnotatedFileVersion($originalFileRef, $user);
+ $fileRef->store();
+
+ return $this->redirectToFileRef($response, $fileRef);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php b/lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php
new file mode 100644
index 0000000..9ecc356
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Files/FilesAnnotationsIndex.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace JsonApi\Routes\Files;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+class FilesAnnotationsIndex extends JsonApiController
+{
+ protected $allowedIncludePaths = ['file', 'owner', 'parent', 'range', 'terms-of-use'];
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+ */
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!$file = \File::find($args['id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!Authority::canShowFile($user = $this->getUser($request), $file)) {
+ throw new AuthorizationFailedException();
+ }
+
+ return $this->getContentResponse(
+ \File::findBySQL(
+ "`metadata` LIKE :annotationref ORDER BY `name`",
+ ['annotationref' => '%"annotations:original_file_id":"' .$file->id . '"%']
+ )
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php b/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php
index fea4f16..65d3b30 100644
--- a/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php
+++ b/lib/classes/JsonApi/Routes/Files/RoutesHelperTrait.php
@@ -185,6 +185,7 @@ trait RoutesHelperTrait
/**
* @SuppressWarnings(PHPMD.Superglobals)
+ * @return \FileRef|null
*/
protected function handleUpload(Request $request, \FolderType $folder)
{
diff --git a/lib/filesystem/StandardFile.php b/lib/filesystem/StandardFile.php
index b11e2f5..2aaf3d4 100644
--- a/lib/filesystem/StandardFile.php
+++ b/lib/filesystem/StandardFile.php
@@ -248,6 +248,13 @@ class StandardFile implements FileType, ArrayAccess, StandardFileInterface
['data-dialog' => ''],
'file-edit'
);
+ if ('application/pdf' === $this->fileref->file->mime_type) {
+ $actionMenu->addLink(
+ URLHelper::getURL('dispatch.php/file/annotate_pdf/' . $this->fileref->id),
+ _('PDF-Werkzeuge'),
+ Icon::create('comment'),
+ ['data-dialog' => 'size=big'] );
+ }
$actionMenu->addLink(
URLHelper::getURL('dispatch.php/file/update/' . $this->fileref->id),
_('Datei aktualisieren'),
@@ -344,6 +351,14 @@ class StandardFile implements FileType, ArrayAccess, StandardFileInterface
URLHelper::getURL("dispatch.php/file/edit/{$this->getId()}", $extra_link_params),
['data-dialog' => '']
);
+
+ if ('application/pdf' === $this->getMimeType()) {
+ $buttons[] = Studip\LinkButton::createComment(
+ _('PDF-Werkzeuge'),
+ URLHelper::getURL("dispatch.php/file/annotate_pdf/{$this->getId()}", $extra_link_params),
+ ['data-dialog' => 'size=big']
+ );
+ }
}
if ($this->isDownloadable($GLOBALS['user']->id)) {
$buttons[] = Studip\LinkButton::createDownload(
diff --git a/lib/models/FileRef.php b/lib/models/FileRef.php
index 34b3841..070ca0a 100644
--- a/lib/models/FileRef.php
+++ b/lib/models/FileRef.php
@@ -683,4 +683,105 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
$sql_data = self::getUploadedFilesSql($user_id, $begin, $end, $course_id, $unknown_license_only);
return self::countBySql($sql_data['sql'], $sql_data['params']);
}
+
+ /**
+ * Creates appropriate metadata for an annotated (PDF) file.
+ *
+ * @param File $originalFile the original file that was annotated
+ * @param User $annotatorId the annotating person
+ * @param User $owner
+ * @return FileRef
+ * @throws Exception
+ */
+ public function setAnnotatedFileVersion(FileRef $originalFileRef, User $annotator)
+ {
+ $metadata = $originalFileRef->file->metadata;
+
+ $owner = User::find($metadata['homework:visible_for_user_id']
+ ?? $originalFileRef->user_id);
+
+ // Set metadata indicating who has annotated this file.
+ $annotators = $metadata['annotations:annotated_by']?->getArrayCopy() ?? [];
+ if (!in_array($annotator->id, $annotators)) {
+ $annotators[] = $annotator->id;
+ }
+
+ // Increment the version number by one each time.
+ $version = ($metadata['annotations:version'] ?? 0) + 1;
+
+ // Generate new file name with version number and the names of the users who annotated it
+ $originalFilename = $metadata['annotations:original_filename']
+ ?? $originalFileRef->file->name;
+ $annotators_names = \User::findAndMapMany(function ($user) {
+ return $user->getFullName();
+ }, $annotators);
+
+ $pathinfo = pathinfo($originalFilename);
+
+ $annotatedFilename = $pathinfo['filename']
+ . ' Version ' . $version . ' mit Anmerkungen von ' . join(', ', $annotators_names)
+ . '.' . $pathinfo['extension'];
+
+ $this->file->metadata = [
+ 'homework:visible_for_user_id' => $owner->id,
+ 'annotations:annotated_by' => $annotators,
+ 'annotations:original_filename' => $originalFilename,
+ 'annotations:original_file_id' => $originalFileRef->file->id,
+ 'annotations:version' => $version,
+ ];
+ // Set the filename on the file and store it.
+ $this->file->name = $annotatedFilename;
+ $this->file->store();
+
+ $this->name = $this->file->name;
+
+ // TODO Is this the correct behavior? Maybe everyone who annotated the file should be notified?
+ $this->notifyFileOwner($owner, $annotator, $originalFileRef->folder->getTypedFolder());
+ return $this;
+ }
+
+ /**
+ * Notify file owner that their file was edited/annotated by another user.
+ * @param User $owner
+ * @param User $editor
+ * @param FolderType $folder
+ * @return void
+ */
+ public function notifyFileOwner(User $owner, User $editor, FolderType $folder)
+ {
+ $tutorName = $editor->getFullName();
+ $profileLink = \URLHelper::getLink('dispatch.php/profile?username=' . $editor->username);
+ $folderName = $folder->name;
+ $folderLink = \URLHelper::getLink(
+ 'dispatch.php/course/files/index/' . $folder->getId(),
+ ['cid' => $folder->range_id]
+ );
+ $rangeName = $folder->getRangeObject()->getFullName();
+ $rangeUrl = URLHelper::getLink('dispatch.php/' . $folder->getRangeObject()->getRangeUrl(), ['cid' => $folder->range_id]);
+ $fileLink = \URLHelper::getLink('dispatch.php/file/details/' . $this->id, ['cid' => $folder->range_id]);
+
+ setTempLanguage($owner->id);
+ $subject = _('Benachrichtigung Ă¼ber PDF-Annotationen');
+ $message = '<!--HTML--><p>';
+ $message .= sprintf(_('%1$s hat im Verzeichnis "%2$s" in "%3$s" eine kommentierte Version Ihrer PDF-Datei hochgeladen: %4$s'),
+ static::formatLink($tutorName, $profileLink),
+ static::formatLink($folderName, $folderLink),
+ static::formatLink($rangeName, $rangeUrl),
+ static::formatLink($this->name, $fileLink),
+ );
+ $message .= '</p>';
+ restoreLanguage();
+
+ messaging::sendSystemMessage($owner->id, $subject, $message);
+ }
+
+ /**
+ * Helper function for formatting a link.
+ * @param string $text
+ * @param string $url
+ * @return string
+ */
+ private static function formatLink(string $text, string $url): string {
+ return sprintf('<a href="%s">%s</a>', htmlReady($url), htmlReady($text));
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 8ce8a07..c552be9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"@playwright/test": "^1.33.0",
"@popperjs/core": "^2.11.2",
"@rsdoctor/webpack-plugin": "^1.0.1",
+ "@studip/pdfjs-studip": "^5.3.54",
"@types/jquery": "^3.5.16",
"@types/jqueryui": "^1.12.16",
"@types/lodash": "^4.14.191",
@@ -3618,6 +3619,199 @@
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"dev": true
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.70.tgz",
+ "integrity": "sha512-nD6NGa4JbNYSZYsTnLGrqe9Kn/lCkA4ybXt8sx5ojDqZjr2i0TWAHxx/vhgfjX+i3hCdKWufxYwi7CfXqtITSA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.70",
+ "@napi-rs/canvas-darwin-arm64": "0.1.70",
+ "@napi-rs/canvas-darwin-x64": "0.1.70",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.70",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.70",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.70",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.70",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.70",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.70",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.70"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.70.tgz",
+ "integrity": "sha512-I/YOuQ0wbkVYxVaYtCgN42WKTYxNqFA0gTcTrHIGG1jfpDSyZWII/uHcjOo4nzd19io6Y4+/BqP8E5hJgf9OmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.70.tgz",
+ "integrity": "sha512-4pPGyXetHIHkw2TOJHujt3mkCP8LdDu8+CT15ld9Id39c752RcI0amDHSuMLMQfAjvusA9B5kKxazwjMGjEJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.70.tgz",
+ "integrity": "sha512-+2N6Os9LbkmDMHL+raknrUcLQhsXzc5CSXRbXws9C3pv/mjHRVszQ9dhFUUe9FjfPhCJznO6USVdwOtu7pOrzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.70.tgz",
+ "integrity": "sha512-QjscX9OaKq/990sVhSMj581xuqLgiaPVMjjYvWaCmAJRkNQ004QfoSMEm3FoTqM4DRoquP8jvuEXScVJsc1rqQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.70.tgz",
+ "integrity": "sha512-LNakMOwwqwiHIwMpnMAbFRczQMQ7TkkMyATqFCOtUJNlE6LPP/QiUj/mlFrNbUn/hctqShJ60gWEb52ZTALbVw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.70.tgz",
+ "integrity": "sha512-wBTOllEYNfJCHOdZj9v8gLzZ4oY3oyPX8MSRvaxPm/s7RfEXxCyZ8OhJ5xAyicsDdbE5YBZqdmaaeP5+xKxvtg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.70.tgz",
+ "integrity": "sha512-GVUUPC8TuuFqHip0rxHkUqArQnlzmlXmTEBuXAWdgCv85zTCFH8nOHk/YCF5yo0Z2eOm8nOi90aWs0leJ4OE5Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.70.tgz",
+ "integrity": "sha512-/kvUa2lZRwGNyfznSn5t1ShWJnr/m5acSlhTV3eXECafObjl0VBuA1HJw0QrilLpb4Fe0VLywkpD1NsMoVDROQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.70.tgz",
+ "integrity": "sha512-aqlv8MLpycoMKRmds7JWCfVwNf1fiZxaU7JwJs9/ExjTD8lX2KjsO7CTeAj5Cl4aEuzxUWbJPUUE2Qu9cZ1vfg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.70",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.70.tgz",
+ "integrity": "sha512-Q9QU3WIpwBTVHk4cPfBjGHGU4U0llQYRXgJtFtYqqGNEOKVN4OT6PQ+ve63xwIPODMpZ0HHyj/KLGc9CWc3EtQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -4233,6 +4427,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@studip/pdfjs-studip": {
+ "version": "5.3.54",
+ "resolved": "https://registry.npmjs.org/@studip/pdfjs-studip/-/pdfjs-studip-5.3.54.tgz",
+ "integrity": "sha512-soIy9BcPjLrXWt67JUyO/PHbEFRcfp8+6xntI7OODyr8geNVU3MF2HsepIeTL+OmYU36Ay87XRLm/mE+KyBWNw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.67"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -6160,6 +6367,7 @@
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"dev": true,
+ "optional": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -6173,6 +6381,7 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"dev": true,
+ "optional": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6"
@@ -6799,6 +7008,43 @@
"node": ">=10.13.0"
}
},
+ "node_modules/copy-webpack-plugin": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz",
+ "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "glob-parent": "^6.0.1",
+ "normalize-path": "^3.0.0",
+ "schema-utils": "^4.2.0",
+ "serialize-javascript": "^6.0.2",
+ "tinyglobby": "^0.2.12"
+ },
+ "engines": {
+ "node": ">= 18.12.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^5.1.0"
+ }
+ },
+ "node_modules/copy-webpack-plugin/node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/core-js": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.0.tgz",
@@ -7927,7 +8173,8 @@
"resolved": "https://registry.npmjs.org/dommatrix/-/dommatrix-1.0.3.tgz",
"integrity": "sha512-l32Xp/TLgWb8ReqbVJAFIvXmY7go4nTxxlWiAFyhoQw9RKEOHBZNnyGvJWqDVSPmq3Y9HlM4npqF/T6VMOXhww==",
"deprecated": "dommatrix is no longer maintained. Please use @thednp/dommatrix.",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/dompurify": {
"version": "2.5.8",
@@ -7969,6 +8216,7 @@
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
+ "optional": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -8187,6 +8435,7 @@
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
+ "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -8196,6 +8445,7 @@
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
+ "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -8211,6 +8461,7 @@
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
+ "optional": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -9288,6 +9539,7 @@
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
"dev": true,
+ "optional": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-define-property": "^1.0.1",
@@ -9334,6 +9586,7 @@
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
+ "optional": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -9511,6 +9764,7 @@
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
+ "optional": true,
"engines": {
"node": ">= 0.4"
},
@@ -9590,6 +9844,7 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
+ "optional": true,
"engines": {
"node": ">= 0.4"
},
@@ -9625,6 +9880,7 @@
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
+ "optional": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -11430,6 +11686,7 @@
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
+ "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -12164,6 +12421,7 @@
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz",
"integrity": "sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
"dommatrix": "^1.0.3",
"web-streams-polyfill": "^3.2.1"
@@ -15943,10 +16201,11 @@
}
},
"node_modules/web-streams-polyfill": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
- "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 8"
}
diff --git a/package.json b/package.json
index 31cc444..cce9fac 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@playwright/test": "^1.33.0",
"@popperjs/core": "^2.11.2",
"@rsdoctor/webpack-plugin": "^1.0.1",
+ "@studip/pdfjs-studip": "^5.3.54",
"@types/jquery": "^3.5.16",
"@types/jqueryui": "^1.12.16",
"@types/lodash": "^4.14.191",
diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss
index 2b39a21..e251512 100644
--- a/resources/assets/stylesheets/scss/buttons.scss
+++ b/resources/assets/stylesheets/scss/buttons.scss
@@ -95,6 +95,10 @@ button.button {
}
}
+.ui-button.comment {
+ @include button-with-icon(comment, clickable, info_alt);
+}
+
/* Grouped Buttons */
.button-group {
display: inline-flex;
diff --git a/resources/studip.d.ts b/resources/studip.d.ts
index c8797cc..ab9b78f 100644
--- a/resources/studip.d.ts
+++ b/resources/studip.d.ts
@@ -2,9 +2,16 @@ import { Emitter } from 'mitt';
import { URLHelper } from './assets/javascripts/lib/url_helper.d.ts';
export {};
+type SupportedMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'OPTIONS' | 'DELETE';
+
declare global {
interface Window {
STUDIP: {
+ jsonapi: {
+ withPromises(): {
+ [key in SupportedMethod]: (url: string, options?: object) => Promise<unknown>;
+ }
+ }
INSTALLED_LANGUAGES: { [name: string]: InstalledLanguage };
ABSOLUTE_URI_STUDIP: string;
ASSETS_URL: string;
diff --git a/resources/vue/apps/PdfAnnotator.vue b/resources/vue/apps/PdfAnnotator.vue
new file mode 100644
index 0000000..06d425c
--- /dev/null
+++ b/resources/vue/apps/PdfAnnotator.vue
@@ -0,0 +1,78 @@
+<template>
+ <div class="pdf-annotator">
+ <pdf-js-viewer ref="pdfJsViewer"
+ :file_id="fileRef.id"
+ :user-fullname="userFullname"/>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import PdfJsViewer from '../components/PdfJsViewer.vue';
+import { mapActions, mapGetters } from 'vuex';
+import { httpClient } from "../../assets/javascripts/chunks/vue";
+
+export default defineComponent({
+ name: 'PdfAnnotator',
+ components: { PdfJsViewer },
+ props: {
+ fileRef: {
+ type: Object,
+ required: true
+ },
+ userFullname: {
+ type: String,
+ required: true
+ }
+ },
+ created() {
+ // This event is fired by the 'save' button in the annotate_pdf view.
+ window.STUDIP.eventBus.on('files:save-annotated-pdf', this.savePdf);
+ },
+ beforeUnmount() {
+ window.STUDIP.eventBus.off('files:save-annotated-pdf');
+ },
+ computed: {
+ ...mapGetters({
+ fileRefById: 'file-refs/byId',
+ folderById: 'folders/byId',
+ }),
+ },
+ methods: {
+ ...mapActions({
+ loadFolder: 'folders/loadById',
+ }),
+ /**
+ * Make a request to Stud.IP to save the annotated file.
+ */
+ async savePdf() {
+ const blob = await (this.$refs.pdfJsViewer as any).savePdf();
+ const formData = new FormData();
+ const filename = this.$gettext('%{originalFilename} (korrigiert)', {
+ originalFilename: this.fileRef.name,
+ });
+ formData.append('file', blob, filename);
+ const url = `file-refs/${this.fileRef.id}/annotations`;
+ const res = await httpClient.post(url, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+ window.location.reload();
+ return res;
+ },
+ },
+});
+</script>
+
+<style>
+.pdf-annotator {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow-y: clip;
+}
+.annotate-pdf-root {
+ flex-grow: 1;
+}
+</style>
diff --git a/resources/vue/components/PdfJsViewer.vue b/resources/vue/components/PdfJsViewer.vue
new file mode 100644
index 0000000..8396fda
--- /dev/null
+++ b/resources/vue/components/PdfJsViewer.vue
@@ -0,0 +1,107 @@
+<template>
+ <iframe ref="iframe" :src="iframeUrl" class="pdfjs-viewer-iframe" @load="onIframeLoad"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getLocale } from '../../assets/javascripts/lib/gettext';
+
+interface PdfJsIframe {
+ contentWindow: {
+ setStudipUser(formattedName: string): void;
+ PDFViewerApplication: {
+ pdfDocument: {
+ saveDocument(): Promise<BlobPart>;
+ };
+ };
+ PDFViewerApplicationOptions: {
+ set(field: string, value: unknown): void;
+ setAll(options: Record<string, unknown>): void;
+ };
+ };
+}
+
+export default defineComponent({
+ props: {
+ file_id: {
+ type: String,
+ required: true,
+ },
+ userFullname: {
+ type: String,
+ required: true
+ }
+ },
+ computed: {
+ iframeUrl(): string {
+ return window.STUDIP.URLHelper.getURL('assets/javascripts/pdfjs/web/viewer.html', {
+ file: window.STUDIP.URLHelper.getURL('sendfile.php', { file_id: this.file_id }),
+ });
+ },
+ },
+ mounted() {
+ // This is an event dispatched by PDF.js from within the iframe which
+ // grants us an opportunity to set configuration values before PDF.js
+ // is initialized.
+ // See webViewerLoad() in pdfjs/web/viewer.js.
+ window.document.addEventListener('webviewerloaded', this.onWebViewerLoaded);
+ // Add an event listener for dialog closing so that we can prompt the user
+ // before potentially losing data.
+ $('.studip-dialog').on('dialogbeforeclose', this.beforeDialogClose);
+ },
+ unmounted() {
+ window.document.removeEventListener('webviewerloaded', this.onWebViewerLoaded);
+ },
+ methods: {
+ async savePdf(): Promise<Blob> {
+ const data = await (
+ this.$refs.iframe as unknown as PdfJsIframe
+ ).contentWindow.PDFViewerApplication.pdfDocument.saveDocument();
+ return new Blob([data], { type: 'application/pdf' });
+ },
+ beforeDialogClose(evt: Event) {
+ if (!window.confirm(this.$gettext('Ihre Änderungen wurden noch nicht gespeichert.'))) {
+ evt.preventDefault();
+ evt.stopPropagation();
+ return false;
+ }
+
+ return true;
+ },
+ onWebViewerLoaded(evt: Event) {
+ const iframe = this.$refs.iframe as unknown as PdfJsIframe;
+ // Verify that the event is from our iframe, because there could be
+ // multiple PdfJsViewer components on the same page.
+ if ((evt as CustomEvent).detail.source === iframe.contentWindow) {
+ const locale = getLocale()
+ // Stud.IP uses '_' and PDF.js uses '-' (e.g. 'de_DE', 'de-DE').
+ .replace('_', '-');
+ const options = {
+ 'localeProperties': {
+ lang: locale,
+ },
+ // Disable 'User Preferences' in PDF.js so they will not
+ // conflict with Stud.IP-based configuration.
+ 'disablePreferences': true,
+ 'viewerCssTheme': 1
+ };
+ iframe.contentWindow.PDFViewerApplicationOptions.setAll(options);
+ }
+ },
+ // Get the name of the current user and set it in PDF.js's options so that
+ // it can be applied to the annotations they create and edit.
+ async onIframeLoad(event: Event): Promise<void> {
+ (this.$refs.iframe as unknown as PdfJsIframe).contentWindow.setStudipUser(
+ this.userFullname
+ );
+ },
+ },
+});
+</script>
+
+<style scoped>
+.pdfjs-viewer-iframe {
+ width: 100%;
+ height: 100vh;
+}
+</style>
diff --git a/webpack.common.js b/webpack.common.js
index e2c24cd..c2d2036 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -113,6 +113,10 @@ module.exports = {
from: './node_modules/vuex/dist/vuex.global.prod.js',
to: './javascripts/vuex.global.prod.js',
},
+ {
+ from: './node_modules/@studip/pdfjs-studip',
+ to: './javascripts/pdfjs'
+ },
],
}),
new VueLoaderPlugin(),