diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java index f2c628197ea8..2a22b746b977 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java @@ -146,6 +146,10 @@ public static Path actualPathForPublicPath(URI publicPath) { private static Path actualPathForPublicAttachmentUnitFilePath(URI publicPath, String filename) { Path path = Path.of(publicPath.getPath()); + if (publicPath.toString().contains("/student")) { + String attachmentUnitId = path.getName(4).toString(); + return getAttachmentUnitFilePath().resolve(Path.of(attachmentUnitId, "student", filename)); + } if (!publicPath.toString().contains("/slide")) { String attachmentUnitId = path.getName(4).toString(); return getAttachmentUnitFilePath().resolve(Path.of(attachmentUnitId, filename)); @@ -244,6 +248,9 @@ public static URI publicPathForActualPath(Path path, @Nullable Long entityId) { } private static URI publicPathForActualAttachmentUnitFilePath(Path path, String filename, String id) { + if (path.toString().contains("/student")) { + return URI.create("/api/files/attachments/attachment-unit/" + id + "/student/" + filename); + } if (!path.toString().contains("/slide")) { return URI.create("/api/files/attachments/attachment-unit/" + id + "/" + filename); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java index 11c5d1278609..6acdeeb24ebd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java @@ -75,7 +75,6 @@ import de.tum.cit.aet.artemis.lecture.repository.AttachmentRepository; import de.tum.cit.aet.artemis.lecture.repository.AttachmentUnitRepository; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; -import de.tum.cit.aet.artemis.lecture.repository.SlideRepository; import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; import de.tum.cit.aet.artemis.programming.domain.ProjectType; @@ -104,8 +103,6 @@ public class FileResource { private final AttachmentUnitRepository attachmentUnitRepository; - private final SlideRepository slideRepository; - private final FileUploadSubmissionRepository fileUploadSubmissionRepository; private final AttachmentRepository attachmentRepository; @@ -126,7 +123,7 @@ public class FileResource { private final LectureUnitService lectureUnitService; - public FileResource(SlideRepository slideRepository, AuthorizationCheckService authorizationCheckService, FileService fileService, ResourceLoaderService resourceLoaderService, + public FileResource(AuthorizationCheckService authorizationCheckService, FileService fileService, ResourceLoaderService resourceLoaderService, LectureRepository lectureRepository, FileUploadSubmissionRepository fileUploadSubmissionRepository, AttachmentRepository attachmentRepository, AttachmentUnitRepository attachmentUnitRepository, AuthorizationCheckService authCheckService, UserRepository userRepository, ExamUserRepository examUserRepository, QuizQuestionRepository quizQuestionRepository, DragItemRepository dragItemRepository, CourseRepository courseRepository, LectureUnitService lectureUnitService) { @@ -140,7 +137,6 @@ public FileResource(SlideRepository slideRepository, AuthorizationCheckService a this.userRepository = userRepository; this.authorizationCheckService = authorizationCheckService; this.examUserRepository = examUserRepository; - this.slideRepository = slideRepository; this.quizQuestionRepository = quizQuestionRepository; this.dragItemRepository = dragItemRepository; this.courseRepository = courseRepository; @@ -476,7 +472,7 @@ public ResponseEntity getLecturePdfAttachmentsMerged(@PathVariable Long * @return The requested file, 403 if the logged-in user is not allowed to access it, or 404 if the file doesn't exist */ @GetMapping("files/attachments/attachment-unit/{attachmentUnitId}/*") - @EnforceAtLeastStudent + @EnforceAtLeastTutor public ResponseEntity getAttachmentUnitAttachment(@PathVariable Long attachmentUnitId) { log.debug("REST request to get the file for attachment unit {} for students", attachmentUnitId); AttachmentUnit attachmentUnit = attachmentUnitRepository.findByIdElseThrow(attachmentUnitId); @@ -547,8 +543,10 @@ public ResponseEntity getAttachmentUnitAttachmentSlide(@PathVariable Lon checkAttachmentAuthorizationOrThrow(course, attachment); - Slide slide = slideRepository.findSlideByAttachmentUnitIdAndSlideNumber(attachmentUnitId, Integer.parseInt(slideNumber)); - String directoryPath = slide.getSlideImagePath(); + // Get the all the slides without hidden slides and get the visible slide by slide index + Slide visibleSlide = attachmentUnit.getSlides().stream().filter(slide -> slide.getHidden() == null).toList().get(Integer.parseInt(slideNumber) - 1); + + String directoryPath = visibleSlide.getSlideImagePath(); // Use regular expression to match and extract the file name with ".png" format Pattern pattern = Pattern.compile(".*/([^/]+\\.png)$"); @@ -557,14 +555,38 @@ public ResponseEntity getAttachmentUnitAttachmentSlide(@PathVariable Lon if (matcher.matches()) { String fileName = matcher.group(1); return buildFileResponse( - FilePathService.getAttachmentUnitFilePath().resolve(Path.of(attachmentUnit.getId().toString(), "slide", String.valueOf(slide.getSlideNumber()))), fileName, - true); + FilePathService.getAttachmentUnitFilePath().resolve(Path.of(attachmentUnit.getId().toString(), "slide", String.valueOf(visibleSlide.getSlideNumber()))), + fileName, false); } else { throw new EntityNotFoundException("Slide", slideNumber); } } + /** + * GET files/attachments/attachment-unit/{attachmentUnitId}/student/* : Get the student version of attachment unit by attachment unit id + * + * @param attachmentUnitId ID of the attachment unit, the student version belongs to + * @return The requested file, 403 if the logged-in user is not allowed to access it, or 404 if the file doesn't exist + */ + @GetMapping("files/attachments/attachment-unit/{attachmentUnitId}/student/*") + @EnforceAtLeastStudent + public ResponseEntity getAttachmentUnitStudentVersion(@PathVariable Long attachmentUnitId) { + log.debug("REST request to get the student version of attachment Unit : {}", attachmentUnitId); + AttachmentUnit attachmentUnit = attachmentUnitRepository.findByIdElseThrow(attachmentUnitId); + Attachment attachment = attachmentUnit.getAttachment(); + + // check if hidden link is available in the attachment + String studentVersion = attachment.getStudentVersion(); + if (studentVersion == null) { + return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), false); + } + + String fileName = studentVersion.substring(studentVersion.lastIndexOf("/") + 1); + + return buildFileResponse(FilePathService.getAttachmentUnitFilePath().resolve(Path.of(attachmentUnit.getId().toString(), "student")), fileName, false); + } + /** * Builds the response with headers, body and content type for specified path containing the file name * diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Attachment.java b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Attachment.java index 6806850fd86b..96c481992d7c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Attachment.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Attachment.java @@ -61,6 +61,9 @@ public class Attachment extends DomainObject implements Serializable { @JoinColumn(name = "attachment_unit_id") private AttachmentUnit attachmentUnit; + @Column(name = "student_version") + private String studentVersion; + // jhipster-needle-entity-add-field - JHipster will add fields here, do not remove public String getName() { @@ -135,6 +138,14 @@ public void setAttachmentUnit(AttachmentUnit attachmentUnit) { this.attachmentUnit = attachmentUnit; } + public String getStudentVersion() { + return studentVersion; + } + + public void setStudentVersion(String studentVersion) { + this.studentVersion = studentVersion; + } + public Boolean isVisibleToStudents() { if (releaseDate == null) { // no release date means the attachment is visible to students return Boolean.TRUE; diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/AttachmentUnit.java b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/AttachmentUnit.java index 1af955b43d89..65488a7b5253 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/AttachmentUnit.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/AttachmentUnit.java @@ -28,7 +28,7 @@ public class AttachmentUnit extends LectureUnit { @JsonIgnoreProperties(value = "attachmentUnit", allowSetters = true) private Attachment attachment; - @OneToMany(mappedBy = "attachmentUnit", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @OneToMany(mappedBy = "attachmentUnit", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) @JsonIgnoreProperties("attachmentUnit") private List slides = new ArrayList<>(); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Slide.java b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Slide.java index 1eb21299bc15..b89be6e2e39c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Slide.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/Slide.java @@ -1,5 +1,7 @@ package de.tum.cit.aet.artemis.lecture.domain; +import java.util.Date; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; @@ -27,6 +29,9 @@ public class Slide extends DomainObject { @Column(name = "slide_number") private int slideNumber; + @Column(name = "hidden") + private Date hidden; + public AttachmentUnit getAttachmentUnit() { return attachmentUnit; } @@ -50,4 +55,12 @@ public int getSlideNumber() { public void setSlideNumber(int slideNumber) { this.slideNumber = slideNumber; } + + public Date getHidden() { + return hidden; + } + + public void setHidden(Date hidden) { + this.hidden = hidden; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java index 342e463b5650..d547f3c243b9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java @@ -107,11 +107,13 @@ public AttachmentUnit createAttachmentUnit(AttachmentUnit attachmentUnit, Attach * @param updateUnit The new attachment unit data. * @param updateAttachment The new attachment data. * @param updateFile The optional file. + * @param studentVersionFile The student version of the original file. * @param keepFilename Whether to keep the original filename or not. + * @param hiddenPages The hidden pages of attachment unit. * @return The updated attachment unit. */ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit, AttachmentUnit updateUnit, Attachment updateAttachment, MultipartFile updateFile, - boolean keepFilename) { + MultipartFile studentVersionFile, boolean keepFilename, String hiddenPages) { Set existingCompetencyLinks = new HashSet<>(existingAttachmentUnit.getCompetencyLinks()); existingAttachmentUnit.setDescription(updateUnit.getDescription()); @@ -126,8 +128,9 @@ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit throw new BadRequestAlertException("Attachment unit must be associated to an attachment", "AttachmentUnit", "attachmentMissing"); } - updateAttachment(existingAttachment, updateAttachment, savedAttachmentUnit); + updateAttachment(existingAttachment, updateAttachment, savedAttachmentUnit, hiddenPages); handleFile(updateFile, existingAttachment, keepFilename, savedAttachmentUnit.getId()); + handleStudentVersionFile(studentVersionFile, existingAttachment, savedAttachmentUnit.getId()); final int revision = existingAttachment.getVersion() == null ? 1 : existingAttachment.getVersion() + 1; existingAttachment.setVersion(revision); Attachment savedAttachment = attachmentRepository.saveAndFlush(existingAttachment); @@ -145,7 +148,7 @@ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit } // Split the updated file into single slides only if it is a pdf if (Objects.equals(FilenameUtils.getExtension(updateFile.getOriginalFilename()), "pdf")) { - slideSplitterService.splitAttachmentUnitIntoSingleSlides(savedAttachmentUnit); + slideSplitterService.splitAttachmentUnitIntoSingleSlides(savedAttachmentUnit, hiddenPages); } if (pyrisWebhookService.isPresent() && irisSettingsRepository.isPresent()) { pyrisWebhookService.get().autoUpdateAttachmentUnitsInPyris(savedAttachmentUnit.getLecture().getCourse().getId(), List.of(savedAttachmentUnit)); @@ -165,14 +168,18 @@ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit * @param existingAttachment the existing attachment * @param updateAttachment the new attachment containing updated information * @param attachmentUnit the attachment unit to update + * @param hiddenPages the hidden pages in the attachment */ - private void updateAttachment(Attachment existingAttachment, Attachment updateAttachment, AttachmentUnit attachmentUnit) { + private void updateAttachment(Attachment existingAttachment, Attachment updateAttachment, AttachmentUnit attachmentUnit, String hiddenPages) { // Make sure that the original references are preserved. existingAttachment.setAttachmentUnit(attachmentUnit); existingAttachment.setReleaseDate(updateAttachment.getReleaseDate()); existingAttachment.setName(updateAttachment.getName()); existingAttachment.setReleaseDate(updateAttachment.getReleaseDate()); existingAttachment.setAttachmentType(updateAttachment.getAttachmentType()); + if (hiddenPages == null && existingAttachment.getStudentVersion() != null) { + existingAttachment.setStudentVersion(null); + } } /** @@ -191,6 +198,30 @@ private void handleFile(MultipartFile file, Attachment attachment, boolean keepF } } + /** + * Handles the student version file of an attachment, updates its reference in the database, + * and deletes the old version if it exists. + * + * @param studentVersionFile the new student version file to be saved + * @param attachment the existing attachment + * @param attachmentUnitId the id of the attachment unit + */ + public void handleStudentVersionFile(MultipartFile studentVersionFile, Attachment attachment, Long attachmentUnitId) { + if (studentVersionFile != null) { + // Delete the old student version + if (attachment.getStudentVersion() != null) { + URI oldStudentVersionPath = URI.create(attachment.getStudentVersion()); + fileService.schedulePathForDeletion(FilePathService.actualPathForPublicPathOrThrow(oldStudentVersionPath), 0); + this.fileService.evictCacheForPath(FilePathService.actualPathForPublicPathOrThrow(oldStudentVersionPath)); + } + + // Update student version of attachment + Path basePath = FilePathService.getAttachmentUnitFilePath().resolve(attachmentUnitId.toString()); + Path savePath = fileService.saveFile(studentVersionFile, basePath.resolve("student"), true); + attachment.setStudentVersion(FilePathService.publicPathForActualPath(savePath, attachmentUnitId).toString()); + } + } + /** * If a file was provided the cache for that file gets evicted. * diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java index 9bf8edba5bea..d82831be92c3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java @@ -30,6 +30,7 @@ import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; import de.tum.cit.aet.artemis.lecture.domain.Lecture; import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; +import de.tum.cit.aet.artemis.lecture.domain.Slide; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; @Profile(PROFILE_CORE) @@ -186,4 +187,18 @@ public void ingestLecturesInPyris(Set lectures) { } } } + + /** + * Filters the slides of all attachment units in a given lecture to exclude slides where `hidden` is not null. + * + * @param lectureWithAttachmentUnits the lecture containing attachment units + */ + public void filterHiddenPagesOfAttachmentUnits(Lecture lectureWithAttachmentUnits) { + for (LectureUnit unit : lectureWithAttachmentUnits.getLectureUnits()) { + if (unit instanceof AttachmentUnit attachmentUnit) { + List filteredSlides = attachmentUnit.getSlides().stream().filter(slide -> slide.getHidden() == null).toList(); + attachmentUnit.setSlides(filteredSlides); + } + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/SlideSplitterService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/SlideSplitterService.java index 2801cab5c02f..e0621b5f14ff 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/SlideSplitterService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/SlideSplitterService.java @@ -8,6 +8,11 @@ import java.io.IOException; import java.net.URI; import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import javax.imageio.ImageIO; @@ -39,6 +44,8 @@ public class SlideSplitterService { private static final Logger log = LoggerFactory.getLogger(SlideSplitterService.class); + static final LocalDate FOREVER = LocalDate.of(9999, 12, 31); + private final FileService fileService; private final SlideRepository slideRepository; @@ -55,11 +62,22 @@ public SlideSplitterService(FileService fileService, SlideRepository slideReposi */ @Async public void splitAttachmentUnitIntoSingleSlides(AttachmentUnit attachmentUnit) { + splitAttachmentUnitIntoSingleSlides(attachmentUnit, null); + } + + /** + * Splits an Attachment Unit file into single slides and saves them as PNG files asynchronously. + * + * @param attachmentUnit The attachment unit to which the slides belong. + * @param hiddenPages The hidden pages of the attachment unit. + */ + @Async + public void splitAttachmentUnitIntoSingleSlides(AttachmentUnit attachmentUnit, String hiddenPages) { Path attachmentPath = FilePathService.actualPathForPublicPath(URI.create(attachmentUnit.getAttachment().getLink())); File file = attachmentPath.toFile(); try (PDDocument document = Loader.loadPDF(file)) { String pdfFilename = file.getName(); - splitAttachmentUnitIntoSingleSlides(document, attachmentUnit, pdfFilename); + splitAttachmentUnitIntoSingleSlides(document, attachmentUnit, pdfFilename, hiddenPages); } catch (IOException e) { log.error("Error while splitting Attachment Unit {} into single slides", attachmentUnit.getId(), e); @@ -68,19 +86,34 @@ public void splitAttachmentUnitIntoSingleSlides(AttachmentUnit attachmentUnit) { } /** - * Splits an Attachment Unit file into single slides and saves them as PNG files. Document is closed where this method is called. + * Splits an Attachment Unit file into single slides and saves them as PNG files or updates existing slides. * * @param attachmentUnit The attachment unit to which the slides belong. * @param document The PDF document that is already loaded. * @param pdfFilename The name of the PDF file. */ public void splitAttachmentUnitIntoSingleSlides(PDDocument document, AttachmentUnit attachmentUnit, String pdfFilename) { + splitAttachmentUnitIntoSingleSlides(document, attachmentUnit, pdfFilename, null); + } + + /** + * Splits an Attachment Unit file into single slides and saves them as PNG files or updates existing slides. + * + * @param attachmentUnit The attachment unit to which the slides belong. + * @param document The PDF document that is already loaded. + * @param pdfFilename The name of the PDF file. + * @param hiddenPages The hidden pages of the attachment unit. + */ + public void splitAttachmentUnitIntoSingleSlides(PDDocument document, AttachmentUnit attachmentUnit, String pdfFilename, String hiddenPages) { log.debug("Splitting Attachment Unit file {} into single slides", attachmentUnit.getAttachment().getName()); try { String fileNameWithOutExt = FilenameUtils.removeExtension(pdfFilename); int numPages = document.getNumberOfPages(); PDFRenderer pdfRenderer = new PDFRenderer(document); + List hiddenPagesList = hiddenPages != null && !hiddenPages.isEmpty() ? Arrays.stream(hiddenPages.split(",")).map(Integer::parseInt).toList() + : Collections.emptyList(); + for (int page = 0; page < numPages; page++) { BufferedImage bufferedImage = pdfRenderer.renderImageWithDPI(page, 72, ImageType.RGB); byte[] imageInByte = bufferedImageToByteArray(bufferedImage, "png"); @@ -89,11 +122,15 @@ public void splitAttachmentUnitIntoSingleSlides(PDDocument document, AttachmentU MultipartFile slideFile = fileService.convertByteArrayToMultipart(filename, ".png", imageInByte); Path savePath = fileService.saveFile(slideFile, FilePathService.getAttachmentUnitFilePath().resolve(attachmentUnit.getId().toString()).resolve("slide") .resolve(String.valueOf(slideNumber)).resolve(filename)); - Slide slideEntity = new Slide(); - slideEntity.setSlideImagePath(FilePathService.publicPathForActualPath(savePath, (long) slideNumber).toString()); - slideEntity.setSlideNumber(slideNumber); - slideEntity.setAttachmentUnit(attachmentUnit); - slideRepository.save(slideEntity); + + Optional existingSlideOpt = Optional.ofNullable(slideRepository.findSlideByAttachmentUnitIdAndSlideNumber(attachmentUnit.getId(), slideNumber)); + Slide slide = existingSlideOpt.orElseGet(Slide::new); + slide.setSlideImagePath(FilePathService.publicPathForActualPath(savePath, (long) slideNumber).toString()); + slide.setSlideNumber(slideNumber); + slide.setAttachmentUnit(attachmentUnit); + slide.setHidden(hiddenPagesList.contains(slideNumber) ? java.sql.Date.valueOf(FOREVER) : null); + slideRepository.save(slide); + } } catch (IOException e) { diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java index 6c280a0ce0be..c829da96700f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java @@ -101,12 +101,12 @@ public ResponseEntity createAttachment(@RequestPart Attachment attac } /** - * PUT /attachments/:id : Updates an existing attachment. + * PUT /attachments/:id : Updates an existing attachment, optionally handling student version of files as well. * * @param attachmentId the id of the attachment to save * @param attachment the attachment to update * @param file the file to save if the file got changed (optional) - * @param notificationText text that will be sent to student group + * @param notificationText text that will be sent to the student group (optional) * @return the ResponseEntity with status 200 (OK) and with body the updated attachment, or with status 400 (Bad Request) if the attachment is not valid, or with status 500 * (Internal Server Error) if the attachment couldn't be updated */ diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java index ec1514ca668b..ac71195e08a1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java @@ -115,6 +115,8 @@ public ResponseEntity getAttachmentUnit(@PathVariable Long attac * @param attachmentUnit the attachment unit with updated content * @param attachment the attachment with updated content * @param file the optional file to upload + * @param hiddenPages the pages to be hidden in the attachment unit + * @param studentVersion the student version of the file to upload * @param keepFilename specifies if the original filename should be kept or not * @param notificationText the text to be used for the notification. No notification will be sent if the parameter is not set * @return the ResponseEntity with status 200 (OK) and with body the updated attachmentUnit @@ -122,14 +124,16 @@ public ResponseEntity getAttachmentUnit(@PathVariable Long attac @PutMapping(value = "lectures/{lectureId}/attachment-units/{attachmentUnitId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @EnforceAtLeastEditor public ResponseEntity updateAttachmentUnit(@PathVariable Long lectureId, @PathVariable Long attachmentUnitId, @RequestPart AttachmentUnit attachmentUnit, - @RequestPart Attachment attachment, @RequestPart(required = false) MultipartFile file, @RequestParam(defaultValue = "false") boolean keepFilename, + @RequestPart Attachment attachment, @RequestPart(required = false) MultipartFile file, @RequestPart(required = false) MultipartFile studentVersion, + @RequestPart(required = false) String hiddenPages, @RequestParam(defaultValue = "false") boolean keepFilename, @RequestParam(value = "notificationText", required = false) String notificationText) { log.debug("REST request to update an attachment unit : {}", attachmentUnit); AttachmentUnit existingAttachmentUnit = attachmentUnitRepository.findWithSlidesAndCompetenciesByIdElseThrow(attachmentUnitId); checkAttachmentUnitCourseAndLecture(existingAttachmentUnit, lectureId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, existingAttachmentUnit.getLecture().getCourse(), null); - AttachmentUnit savedAttachmentUnit = attachmentUnitService.updateAttachmentUnit(existingAttachmentUnit, attachmentUnit, attachment, file, keepFilename); + AttachmentUnit savedAttachmentUnit = attachmentUnitService.updateAttachmentUnit(existingAttachmentUnit, attachmentUnit, attachment, file, studentVersion, keepFilename, + hiddenPages); if (notificationText != null) { groupNotificationService.notifyStudentGroupAboutAttachmentChange(savedAttachmentUnit.getAttachment(), notificationText); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index deeb3ec3861c..26431de3bf19 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java @@ -210,6 +210,7 @@ public ResponseEntity> getLecturesWithSlidesForCourse(@PathVariable Set lectures = lectureRepository.findAllByCourseIdWithAttachmentsAndLectureUnitsAndSlides(courseId); lectures = lectureService.filterVisibleLecturesWithActiveAttachments(course, lectures, user); lectures.forEach(lectureService::filterActiveAttachmentUnits); + lectures.forEach(lectureService::filterHiddenPagesOfAttachmentUnits); return ResponseEntity.ok().body(lectures); } @@ -337,6 +338,7 @@ public ResponseEntity getLectureWithDetailsAndSlides(@PathVariable long competencyApi.addCompetencyLinksToExerciseUnits(lecture); lectureService.filterActiveAttachmentUnits(lecture); lectureService.filterActiveAttachments(lecture, user); + lectureService.filterHiddenPagesOfAttachmentUnits(lecture); return ResponseEntity.ok(lecture); } diff --git a/src/main/resources/config/liquibase/changelog/20241218152849_changelog.xml b/src/main/resources/config/liquibase/changelog/20241218152849_changelog.xml new file mode 100644 index 000000000000..c6e6625caa45 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241218152849_changelog.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index c25b722955e3..7939d4ace9a2 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -43,6 +43,7 @@ + diff --git a/src/main/webapp/app/entities/attachment.model.ts b/src/main/webapp/app/entities/attachment.model.ts index 78e6b639da8d..fe67d282d8a3 100644 --- a/src/main/webapp/app/entities/attachment.model.ts +++ b/src/main/webapp/app/entities/attachment.model.ts @@ -20,4 +20,5 @@ export class Attachment implements BaseEntity { lecture?: Lecture; exercise?: Exercise; attachmentUnit?: AttachmentUnit; + studentVersion?: string; } diff --git a/src/main/webapp/app/entities/lecture-unit/slide.model.ts b/src/main/webapp/app/entities/lecture-unit/slide.model.ts index a9ac55ff3c52..dfd66022b461 100644 --- a/src/main/webapp/app/entities/lecture-unit/slide.model.ts +++ b/src/main/webapp/app/entities/lecture-unit/slide.model.ts @@ -4,4 +4,5 @@ export class Slide implements BaseEntity { public id?: number; public slideImagePath?: string; public slideNumber?: number; + public hidden?: boolean; } diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts index 789aba78aefb..5ca7754161c6 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, HostListener, effect, input, output, signal, viewChild } from '@angular/core'; +import { Component, ElementRef, HostListener, OnInit, input, output, signal, viewChild } from '@angular/core'; import { ArtemisSharedModule } from 'app/shared/shared.module'; type NavigationDirection = 'next' | 'prev'; @@ -10,7 +10,7 @@ type NavigationDirection = 'next' | 'prev'; standalone: true, imports: [ArtemisSharedModule], }) -export class PdfPreviewEnlargedCanvasComponent { +export class PdfPreviewEnlargedCanvasComponent implements OnInit { enlargedContainer = viewChild.required>('enlargedContainer'); enlargedCanvas = viewChild.required>('enlargedCanvas'); @@ -20,6 +20,7 @@ export class PdfPreviewEnlargedCanvasComponent { pdfContainer = input.required(); originalCanvas = input(); totalPages = input(0); + initialPage = input(); // Signals currentPage = signal(1); @@ -28,14 +29,10 @@ export class PdfPreviewEnlargedCanvasComponent { //Outputs isEnlargedViewOutput = output(); - constructor() { - effect( - () => { - this.enlargedContainer().nativeElement.style.top = `${this.pdfContainer().scrollTop}px`; - this.displayEnlargedCanvas(this.originalCanvas()!); - }, - { allowSignalWrites: true }, - ); + ngOnInit() { + this.currentPage.set(this.initialPage()!); + this.enlargedContainer().nativeElement.style.top = `${this.pdfContainer().scrollTop}px`; + this.displayEnlargedCanvas(this.originalCanvas()!); } /** @@ -62,17 +59,16 @@ export class PdfPreviewEnlargedCanvasComponent { /** * Dynamically updates the canvas size within an enlarged view based on the viewport. */ - adjustCanvasSize = () => { + adjustCanvasSize() { const canvasElements = this.pdfContainer().querySelectorAll('.pdf-canvas-container canvas'); if (this.currentPage() - 1 < canvasElements.length) { const canvas = canvasElements[this.currentPage() - 1] as HTMLCanvasElement; this.updateEnlargedCanvas(canvas); } - }; + } displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { this.isEnlargedCanvasLoading.set(true); - this.currentPage.set(Number(originalCanvas.id)); this.toggleBodyScroll(true); setTimeout(() => { this.updateEnlargedCanvas(originalCanvas); diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html index 01ca1b9617a2..7c6aa549ceeb 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html @@ -1,10 +1,39 @@ -
+
+ @for (page of totalPagesArray(); track page) { +
+ @if (hiddenPages()!.has(page)) { + +
+ + + +
+
+ } + + {{ page }} + + @if (loadedPages().has(page)) { + + } + @if (isAttachmentUnit()) { + + } +
+ } @if (isEnlargedView()) { } diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss index cbe9131ec39c..790ab37b1656 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss @@ -8,7 +8,6 @@ border: 1px solid var(--border-color); padding: 10px; margin: 10px 0; - width: 100%; box-shadow: 0 2px 5px var(--pdf-preview-pdf-container-shadow); z-index: 0; @@ -24,3 +23,68 @@ .enlarged-canvas { display: contents; } + +.pdf-canvas-container { + position: relative; + display: inline-block; + width: 250px; + height: fit-content; + margin: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + transition: transform 0.3s; +} + +.pdf-overlay { + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + color: white; + background-color: rgba(0, 0, 0, 0.5); + z-index: 2; + transition: opacity 0.3s ease; + opacity: 0; + cursor: pointer; +} + +.pdf-canvas-container:hover { + transform: scale(1.02); + + button { + opacity: 1; + } + + .pdf-overlay { + opacity: 1; + } + + .hidden-icon { + transition: transform 0.3s; + opacity: 0; + } + + .hidden-overlay { + opacity: 0 !important; + } +} + +input[type='checkbox'] { + position: absolute; + top: -5px; + right: -5px; + z-index: 3; +} + +.hide-show-btn { + opacity: 0; + position: absolute; + bottom: -20px; + left: 50%; + transform: translateX(-50%); + cursor: pointer; + z-index: 4; + transition: opacity 0.3s ease; +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts index 84a220ad8cba..f40bba026213 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts @@ -1,10 +1,11 @@ -import { Component, ElementRef, effect, inject, input, output, signal, viewChild } from '@angular/core'; +import { Component, ElementRef, OnChanges, SimpleChanges, inject, input, output, signal, viewChild } from '@angular/core'; import * as PDFJS from 'pdfjs-dist'; import 'pdfjs-dist/build/pdf.worker'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { onError } from 'app/shared/util/global.utils'; import { AlertService } from 'app/core/util/alert.service'; import { PdfPreviewEnlargedCanvasComponent } from 'app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component'; +import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'jhi-pdf-preview-thumbnail-grid-component', @@ -13,36 +14,43 @@ import { PdfPreviewEnlargedCanvasComponent } from 'app/lecture/pdf-preview/pdf-p standalone: true, imports: [ArtemisSharedModule, PdfPreviewEnlargedCanvasComponent], }) -export class PdfPreviewThumbnailGridComponent { +export class PdfPreviewThumbnailGridComponent implements OnChanges { pdfContainer = viewChild.required>('pdfContainer'); - readonly DEFAULT_SLIDE_WIDTH = 250; - // Inputs currentPdfUrl = input(); appendFile = input(); + hiddenPages = input>(); + isAttachmentUnit = input(); // Signals isEnlargedView = signal(false); - totalPages = signal(0); + totalPagesArray = signal>(new Set()); + loadedPages = signal>(new Set()); selectedPages = signal>(new Set()); originalCanvas = signal(undefined); + newHiddenPages = signal(new Set(this.hiddenPages()!)); + initialPageNumber = signal(0); // Outputs isPdfLoading = output(); totalPagesOutput = output(); selectedPagesOutput = output>(); + newHiddenPagesOutput = output>(); // Injected services private readonly alertService = inject(AlertService); - constructor() { - effect( - () => { - this.loadOrAppendPdf(this.currentPdfUrl()!, this.appendFile()); - }, - { allowSignalWrites: true }, - ); + protected readonly faEye = faEye; + protected readonly faEyeSlash = faEyeSlash; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['hiddenPages']) { + this.newHiddenPages.set(new Set(this.hiddenPages()!)); + } + if (changes['currentPdfUrl']) { + this.loadPdf(this.currentPdfUrl()!, this.appendFile()!); + } } /** @@ -51,26 +59,29 @@ export class PdfPreviewThumbnailGridComponent { * @param append Whether the document should be appended to the existing one. * @returns A promise that resolves when the PDF is loaded. */ - async loadOrAppendPdf(fileUrl: string, append = false): Promise { + async loadPdf(fileUrl: string, append: boolean): Promise { this.pdfContainer() .nativeElement.querySelectorAll('.pdf-canvas-container') .forEach((canvas) => canvas.remove()); - this.totalPages.set(0); + this.totalPagesArray.set(new Set()); this.isPdfLoading.emit(true); try { const loadingTask = PDFJS.getDocument(fileUrl); const pdf = await loadingTask.promise; - this.totalPages.set(pdf.numPages); + this.totalPagesArray.set(new Set(Array.from({ length: pdf.numPages }, (_, i) => i + 1))); - for (let i = 1; i <= this.totalPages(); i++) { + for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2 }); - const canvas = this.createCanvas(viewport, i); - const context = canvas.getContext('2d'); - await page.render({ canvasContext: context!, viewport }).promise; - - const canvasContainer = this.createCanvasContainer(canvas, i); - this.pdfContainer().nativeElement.appendChild(canvasContainer); + const viewport = page.getViewport({ scale: 1 }); + const canvas = this.createCanvas(viewport); + const context = canvas.getContext('2d')!; + await page.render({ canvasContext: context, viewport }).promise; + + const container = this.pdfContainer().nativeElement.querySelector(`#pdf-page-${i}`); + if (container) { + container.appendChild(canvas); + this.loadedPages().add(i); + } } if (append) { @@ -79,7 +90,7 @@ export class PdfPreviewThumbnailGridComponent { } catch (error) { onError(this.alertService, error); } finally { - this.totalPagesOutput.emit(this.totalPages()); + this.totalPagesOutput.emit(this.totalPagesArray().size); this.isPdfLoading.emit(false); } } @@ -99,96 +110,56 @@ export class PdfPreviewThumbnailGridComponent { /** * Creates a canvas for each page of the PDF to allow for individual page rendering. * @param viewport The viewport settings used for rendering the page. - * @param pageIndex The index of the page within the PDF document. * @returns A new HTMLCanvasElement configured for the PDF page. */ - createCanvas(viewport: PDFJS.PageViewport, pageIndex: number): HTMLCanvasElement { + private createCanvas(viewport: PDFJS.PageViewport): HTMLCanvasElement { const canvas = document.createElement('canvas'); - canvas.id = `${pageIndex}`; - /* Canvas styling is predefined because Canvas tags do not support CSS classes - * as they are not HTML elements but rather a bitmap drawing surface. - * See: https://stackoverflow.com/a/29675448 - * */ - canvas.height = viewport.height; canvas.width = viewport.width; - const fixedWidth = this.DEFAULT_SLIDE_WIDTH; - const scaleFactor = fixedWidth / viewport.width; - canvas.style.width = `${fixedWidth}px`; - canvas.style.height = `${viewport.height * scaleFactor}px`; + canvas.height = viewport.height; + canvas.style.display = 'block'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; return canvas; } /** - * Creates a container div for each canvas, facilitating layering and interaction. - * @param canvas The canvas element that displays a PDF page. - * @param pageIndex The index of the page within the PDF document. - * @returns A configured div element that includes the canvas and interactive overlays. + * Toggles the visibility state of a page by adding or removing it from the hidden pages set. + * @param pageIndex The index of the page whose visibility is being toggled. + * @param event The event object triggered by the click action. */ - createCanvasContainer(canvas: HTMLCanvasElement, pageIndex: number): HTMLDivElement { - const container = document.createElement('div'); - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - container.id = `pdf-page-${pageIndex}`; - container.classList.add('pdf-canvas-container'); - container.style.cssText = `position: relative; display: inline-block; width: ${canvas.style.width}; height: ${canvas.style.height}; margin: 20px; box-shadow: 0 2px 6px var(--pdf-preview-canvas-shadow);`; - - const overlay = this.createOverlay(pageIndex); - const checkbox = this.createCheckbox(pageIndex); - container.appendChild(canvas); - container.appendChild(overlay); - container.appendChild(checkbox); - - container.addEventListener('mouseenter', () => { - overlay.style.opacity = '1'; - }); - container.addEventListener('mouseleave', () => { - overlay.style.opacity = '0'; - }); - overlay.addEventListener('click', () => this.displayEnlargedCanvas(canvas)); - - return container; + toggleVisibility(pageIndex: number, event: Event): void { + if (this.hiddenPages()!.has(pageIndex)) { + this.hiddenPages()!.delete(pageIndex); + } else { + this.hiddenPages()!.add(pageIndex); + } + this.newHiddenPagesOutput.emit(this.hiddenPages()!); + event.stopPropagation(); } /** - * Generates an interactive overlay for each PDF page to allow for user interactions. - * @param pageIndex The index of the page. - * @returns A div element styled as an overlay. + * Toggles the selection state of a page by adding or removing it from the selected pages set. + * @param pageIndex The index of the page whose selection state is being toggled. + * @param event The change event triggered by the checkbox interaction. */ - private createOverlay(pageIndex: number): HTMLDivElement { - const overlay = document.createElement('div'); - overlay.innerHTML = `${pageIndex}`; - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px; color: white; z-index: 1; transition: opacity 0.3s ease; opacity: 0; cursor: pointer; background-color: var(--pdf-preview-container-overlay)`; - return overlay; - } - - private createCheckbox(pageIndex: number): HTMLDivElement { - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.id = String(pageIndex); - checkbox.style.cssText = `position: absolute; top: -5px; right: -5px; z-index: 4;`; - checkbox.checked = this.selectedPages().has(pageIndex); - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - this.selectedPages().add(Number(checkbox.id)); - this.selectedPagesOutput.emit(this.selectedPages()); - } else { - this.selectedPages().delete(Number(checkbox.id)); - this.selectedPagesOutput.emit(this.selectedPages()); - } - }); - return checkbox; + togglePageSelection(pageIndex: number, event: Event): void { + const checkbox = event.target as HTMLInputElement; + if (checkbox.checked) { + this.selectedPages().add(pageIndex); + } else { + this.selectedPages().delete(pageIndex); + } + this.selectedPagesOutput.emit(this.selectedPages()); } /** * Displays the selected PDF page in an enlarged view for detailed examination. - * @param originalCanvas - The original canvas element of the PDF page to be enlarged. + * @param pageIndex - The index of PDF page to be enlarged. * */ - displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - this.originalCanvas.set(originalCanvas); + displayEnlargedCanvas(pageIndex: number): void { + const canvas = this.pdfContainer().nativeElement.querySelector(`#pdf-page-${pageIndex} canvas`) as HTMLCanvasElement; + this.originalCanvas.set(canvas!); this.isEnlargedView.set(true); + this.initialPageNumber.set(pageIndex); } } diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html index ace60a5014e2..b4b2ec510e1a 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html @@ -43,9 +43,12 @@

} @else {
@@ -67,7 +70,12 @@

- diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.ts index d3271475e580..a96ecc304f5e 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.ts +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, OnDestroy, OnInit, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, computed, effect, inject, signal, viewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AttachmentService } from 'app/lecture/attachment.service'; import { Attachment } from 'app/entities/attachment.model'; @@ -17,6 +17,7 @@ import { MAX_FILE_SIZE } from 'app/shared/constants/input.constants'; import { PdfPreviewThumbnailGridComponent } from 'app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { PDFDocument } from 'pdf-lib'; +import { Slide } from 'app/entities/lecture-unit/slide.model'; @Component({ selector: 'jhi-pdf-preview-component', @@ -44,6 +45,8 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { isFileChanged = signal(false); selectedPages = signal>(new Set()); allPagesSelected = computed(() => this.selectedPages().size === this.totalPages()); + initialHiddenPages = signal>(new Set()); + hiddenPages = signal>(new Set()); // Injected services private readonly route = inject(ActivatedRoute); @@ -77,6 +80,9 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { }); } else if ('attachmentUnit' in data) { this.attachmentUnit.set(data.attachmentUnit); + const hiddenPages: Set = new Set(data.attachmentUnit.slides.filter((page: Slide) => page.hidden).map((page: Slide) => page.slideNumber)); + this.initialHiddenPages.set(new Set(hiddenPages)); + this.hiddenPages.set(new Set(hiddenPages)); this.attachmentUnitSub = this.attachmentUnitService.getAttachmentFile(this.course()!.id!, this.attachmentUnit()!.id!).subscribe({ next: (blob: Blob) => { this.currentPdfBlob.set(blob); @@ -93,6 +99,15 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { this.attachmentUnitSub?.unsubscribe(); } + constructor() { + effect( + () => { + this.hiddenPagesChanged(); + }, + { allowSignalWrites: true }, + ); + } + /** * Triggers the file input to select files. */ @@ -100,8 +115,40 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { this.fileInput().nativeElement.click(); } - updateAttachmentWithFile(): void { - const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); + /** + * Checks if there has been any change between the current set of hidden pages and the new set of hidden pages. + * + * @returns Returns true if the sets differ in size or if any element in `newHiddenPages` is not found in `hiddenPages`, otherwise false. + */ + hiddenPagesChanged() { + if (this.initialHiddenPages()!.size !== this.hiddenPages()!.size) return true; + + for (const elem of this.initialHiddenPages()!) { + if (!this.hiddenPages()!.has(elem)) return true; + } + return false; + } + + /** + * Retrieves an array of hidden page numbers from elements with IDs starting with "show-button-". + * + * @returns An array of strings representing the hidden page numbers. + */ + getHiddenPages() { + return Array.from(document.querySelectorAll('.hide-show-btn.btn-success')) + .map((el) => { + const match = el.id.match(/hide-show-button-(\d+)/); + return match ? parseInt(match[1], 10) : null; + }) + .filter((id) => id !== null); + } + + /** + * Updates the existing attachment file or creates a student version of the attachment with hidden files. + */ + async updateAttachmentWithFile(): Promise { + const pdfFileName = this.attachment()?.name ?? this.attachmentUnit()?.name ?? ''; + const pdfFile = new File([this.currentPdfBlob()!], `${pdfFileName}.pdf`, { type: 'application/pdf' }); if (pdfFile.size > MAX_FILE_SIZE) { this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); @@ -124,7 +171,6 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { }); } else if (this.attachmentUnit()) { this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); - this.attachmentToBeEdited()!.version!++; this.attachmentToBeEdited()!.uploadDate = dayjs(); const formData = new FormData(); @@ -132,6 +178,14 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); + const finalHiddenPages = this.getHiddenPages(); + + if (finalHiddenPages.length > 0) { + const pdfFileWithHiddenPages = await this.createStudentVersionOfAttachment(finalHiddenPages); + formData.append('studentVersion', pdfFileWithHiddenPages!); + formData.append('hiddenPages', finalHiddenPages.join(',')); + } + this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ next: () => { this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); @@ -184,9 +238,9 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const pagesToDelete = Array.from(this.selectedPages()!) .map((page) => page - 1) .sort((a, b) => b - a); - pagesToDelete.forEach((pageIndex) => { - pdfDoc.removePage(pageIndex); - }); + + this.updateHiddenPages(pagesToDelete); + pagesToDelete.forEach((pageIndex) => pdfDoc.removePage(pageIndex)); this.isFileChanged.set(true); const pdfBytes = await pdfDoc.save(); @@ -204,6 +258,51 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { } } + /** + * Updates hidden pages after selected pages are deleted. + * @param pagesToDelete - Array of pages to be deleted (0-indexed). + */ + updateHiddenPages(pagesToDelete: number[]) { + const updatedHiddenPages = new Set(); + this.hiddenPages().forEach((hiddenPage) => { + // Adjust hiddenPage based on the deleted pages + const adjustedPage = pagesToDelete.reduce((acc, pageIndex) => { + if (acc === pageIndex + 1) { + return; + } + return pageIndex < acc - 1 ? acc - 1 : acc; + }, hiddenPage); + if (adjustedPage !== -1) { + updatedHiddenPages.add(adjustedPage!); + } + }); + this.hiddenPages.set(updatedHiddenPages); + } + + /** + * Creates a student version of the current PDF attachment by removing specified pages. + * + * @param hiddenPages - An array of page numbers to be removed from the original PDF. + * @returns A promise that resolves to a new `File` object representing the modified PDF, or undefined if an error occurs. + */ + async createStudentVersionOfAttachment(hiddenPages: number[]) { + try { + const fileName = this.attachmentUnit()!.attachment!.name; + const existingPdfBytes = await this.currentPdfBlob()!.arrayBuffer(); + const hiddenPdfDoc = await PDFDocument.load(existingPdfBytes); + + const pagesToDelete = hiddenPages.map((page) => page - 1).sort((a, b) => b - a); + pagesToDelete.forEach((pageIndex) => { + hiddenPdfDoc.removePage(pageIndex); + }); + + const pdfBytes = await hiddenPdfDoc.save(); + return new File([pdfBytes], `${fileName}.pdf`, { type: 'application/pdf' }); + } catch (error) { + this.alertService.error('artemisApp.attachment.pdfPreview.pageDeleteError', { error: error.message }); + } + } + /** * Adds a selected PDF file at the end of the current PDF document. * @param event - The event containing the file input. diff --git a/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.html b/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.html index be1a7c5c1ea9..845915f03adf 100644 --- a/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.html +++ b/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.html @@ -2,12 +2,14 @@ [lectureUnit]="lectureUnit()" [icon]="getAttachmentIcon()" [showViewIsolatedButton]="true" + [showOriginalVersionButton]="!!lectureUnit().attachment?.studentVersion" [viewIsolatedButtonLabel]="'artemisApp.attachmentUnit.download'" [viewIsolatedButtonIcon]="faDownload" [isPresentationMode]="isPresentationMode()" (onCompletion)="toggleCompletion($event)" (onCollapse)="toggleCollapse($event)" (onShowIsolated)="handleDownload()" + (onShowOriginalVersion)="handleOriginalVersion()" > @if (lectureUnit().attachment?.uploadDate) {
diff --git a/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.ts b/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.ts index ecc6c9f39f85..5f870e4e95cd 100644 --- a/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.ts +++ b/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.ts @@ -44,13 +44,27 @@ export class AttachmentUnitComponent extends LectureUnitDirective{{ lectureUnit().name ?? '' }} }
+ @if (showOriginalVersionButton() && !isStudentPath()) { + + } @if (showViewIsolatedButton()) { - @for (slide of unit.slides; track slide.id) { + @for (slide of unit.slides; track slide.id; let index = $index) { } @empty { diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts index 09750e9c7b03..9a12fe33d76f 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts @@ -9,6 +9,7 @@ import { Slide } from 'app/entities/lecture-unit/slide.model'; import { LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface'; import { sanitizeStringForMarkdownEditor } from 'app/shared/util/markdown.util'; +import { FileService } from 'app/shared/http/file.service'; interface LectureWithDetails { id: number; @@ -23,6 +24,7 @@ interface LectureAttachmentReferenceActionArgs { attachmentUnit?: AttachmentUnit; slide?: Slide; attachment?: Attachment; + slideIndex?: number; } /** @@ -34,6 +36,8 @@ export class LectureAttachmentReferenceAction extends TextEditorAction { lecturesWithDetails: LectureWithDetails[] = []; + private readonly fileService: FileService; + constructor( private readonly metisService: MetisService, private readonly lectureService: LectureService, @@ -92,8 +96,8 @@ export class LectureAttachmentReferenceAction extends TextEditorAction { } break; case ReferenceType.SLIDE: - if (args.attachmentUnit && args.slide) { - this.insertSlideReference(editor, args.attachmentUnit, args.slide); + if (args.attachmentUnit && args.slide && args.slideIndex) { + this.insertSlideReference(editor, args.attachmentUnit, args.slide, args.slideIndex); } else { throw new Error(`[${this.id}] No attachment unit or slide provided to reference.`); } @@ -121,18 +125,19 @@ export class LectureAttachmentReferenceAction extends TextEditorAction { this.replaceTextAtCurrentSelection(editor, `[attachment]${sanitizeStringForMarkdownEditor(attachment.name)}(${shortLink})[/attachment]`); } - insertSlideReference(editor: TextEditor, attachmentUnit: AttachmentUnit, slide: Slide): void { + insertSlideReference(editor: TextEditor, attachmentUnit: AttachmentUnit, slide: Slide, slideIndex: number): void { const shortLink = slide.slideImagePath?.split('attachments/')[1]; - // Remove the trailing slash and the file name. - const shortLinkWithoutFileName = shortLink?.replace(new RegExp(`[^/]*${'.png'}`), '').replace(/\/$/, ''); + // Extract just the first part of the path up to /slide/ + const shortLinkWithoutFileName = shortLink?.match(/attachment-unit\/\d+\/slide\//)?.[0]; this.replaceTextAtCurrentSelection( editor, - `[slide]${sanitizeStringForMarkdownEditor(attachmentUnit.name)} Slide ${slide.slideNumber}(${shortLinkWithoutFileName})[/slide]`, + `[slide]${sanitizeStringForMarkdownEditor(attachmentUnit.name)} Slide ${slideIndex}(${shortLinkWithoutFileName}${slideIndex})[/slide]`, ); } insertAttachmentUnitReference(editor: TextEditor, attachmentUnit: AttachmentUnit): void { - const shortLink = attachmentUnit.attachment?.link!.split('attachments/')[1]; + const link = attachmentUnit.attachment!.studentVersion || this.fileService.createStudentLink(attachmentUnit.attachment!.link!); + const shortLink = link.split('attachments/')[1]; this.replaceTextAtCurrentSelection(editor, `[lecture-unit]${sanitizeStringForMarkdownEditor(attachmentUnit.name)}(${shortLink})[/lecture-unit]`); } } diff --git a/src/main/webapp/i18n/de/lectureUnit.json b/src/main/webapp/i18n/de/lectureUnit.json index 2ed9397e04ff..aba68646c98d 100644 --- a/src/main/webapp/i18n/de/lectureUnit.json +++ b/src/main/webapp/i18n/de/lectureUnit.json @@ -28,6 +28,7 @@ "updated": "Texteinheit aktualisiert", "tooltip": "Das ist eine Texteinheit", "isolated": "Isoliert öffnen", + "originalVersion": "Originalversion", "notReleasedTooltip": "Text nur sichtbar für Tutor:innen und Lehrende. Veröffentlichungsdatum:", "markdownHelp": "Hier kann Markdown eingegeben werden. Mehr Informationen:", "cachedMarkdown": "Artemis hat ungespeichertes Markdown im local storage gefunden. Möchtest du es laden? Speicherdatum:", diff --git a/src/main/webapp/i18n/en/lectureUnit.json b/src/main/webapp/i18n/en/lectureUnit.json index d54a06f188b1..e90c9c5cf27b 100644 --- a/src/main/webapp/i18n/en/lectureUnit.json +++ b/src/main/webapp/i18n/en/lectureUnit.json @@ -28,6 +28,7 @@ "updated": "Text unit updated", "tooltip": "This is a text unit", "isolated": "View Isolated", + "originalVersion": "Original Version", "notReleasedTooltip": "Text only visible to teaching assistants and instructors. Release date:", "markdownHelp": "You can enter Markdown here. More information: ", "cachedMarkdown": "Artemis found some unsaved markdown in the local storage. Do you want to load it? Save Date:", diff --git a/src/test/java/de/tum/cit/aet/artemis/core/service/FilePathServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/core/service/FilePathServiceTest.java index 2f51c9677b9d..fc6f5be94ebe 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/service/FilePathServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/service/FilePathServiceTest.java @@ -32,6 +32,9 @@ void testActualPathForPublicPath() { actualPath = FilePathService.actualPathForPublicPath(URI.create("/api/files/attachments/attachment-unit/4/slide/1/1.jpg")); assertThat(actualPath).isEqualTo(Path.of("uploads", "attachments", "attachment-unit", "4", "slide", "1", "1.jpg")); + + actualPath = FilePathService.actualPathForPublicPath(URI.create("/api/files/attachments/attachment-unit/4/student/download.pdf")); + assertThat(actualPath).isEqualTo(Path.of("uploads", "attachments", "attachment-unit", "4", "student", "download.pdf")); } @Test diff --git a/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.component.spec.ts b/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.component.spec.ts index 287b08031ac1..f795cfaa88ba 100644 --- a/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.component.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.component.spec.ts @@ -82,12 +82,23 @@ describe('AttachmentUnitComponent', () => { }); it('should handle download', () => { + const createStudentLinkSpy = jest.spyOn(fileService, 'createStudentLink'); const downloadFileSpy = jest.spyOn(fileService, 'downloadFileByAttachmentName'); const onCompletionEmitSpy = jest.spyOn(component.onCompletion, 'emit'); - fixture.detectChanges(); component.handleDownload(); + expect(createStudentLinkSpy).toHaveBeenCalledOnce(); + expect(downloadFileSpy).toHaveBeenCalledOnce(); + expect(onCompletionEmitSpy).toHaveBeenCalledOnce(); + }); + + it('should handle original version', () => { + const downloadFileSpy = jest.spyOn(fileService, 'downloadFileByAttachmentName'); + const onCompletionEmitSpy = jest.spyOn(component.onCompletion, 'emit'); + + component.handleOriginalVersion(); + expect(downloadFileSpy).toHaveBeenCalledOnce(); expect(onCompletionEmitSpy).toHaveBeenCalledOnce(); }); diff --git a/src/test/javascript/spec/component/lecture-unit/lecture-unit/lecture-unit.component.spec.ts b/src/test/javascript/spec/component/lecture-unit/lecture-unit/lecture-unit.component.spec.ts index 5a495dc3487c..8770f541b6b5 100644 --- a/src/test/javascript/spec/component/lecture-unit/lecture-unit/lecture-unit.component.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/lecture-unit/lecture-unit.component.spec.ts @@ -83,4 +83,22 @@ describe('LectureUnitComponent', () => { expect(toggleCollapseSpy).toHaveBeenCalledOnce(); expect(onCollapseEmitSpy).toHaveBeenCalledOnce(); }); + + it('should handle original version view', async () => { + const handleOriginalVersionViewSpy = jest.spyOn(component, 'handleOriginalVersionView'); + const onShowOriginalVersionEmitSpy = jest.spyOn(component.onShowOriginalVersion, 'emit'); + + fixture.componentRef.setInput('showOriginalVersionButton', true); + fixture.detectChanges(); + + const event = new MouseEvent('click'); + const button = fixture.debugElement.query(By.css('#view-original-version-button')); + + expect(button).not.toBeNull(); + + button.nativeElement.dispatchEvent(event); + + expect(handleOriginalVersionViewSpy).toHaveBeenCalledOnce(); + expect(onShowOriginalVersionEmitSpy).toHaveBeenCalledOnce(); + }); }); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts index 968e6830506f..b38332d6a399 100644 --- a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts @@ -6,6 +6,7 @@ import { AlertService } from 'app/core/util/alert.service'; import { HttpClientModule } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; import { PdfPreviewThumbnailGridComponent } from 'app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component'; +import { ElementRef, InputSignal, Signal, SimpleChanges } from '@angular/core'; jest.mock('pdfjs-dist', () => { return { @@ -58,45 +59,104 @@ describe('PdfPreviewThumbnailGridComponent', () => { jest.clearAllMocks(); }); + it('should update newHiddenPages when hiddenPages changes', () => { + const initialHiddenPages = new Set([1, 2, 3]); + const updatedHiddenPages = new Set([4, 5, 6]); + + component.hiddenPages = jest.fn(() => updatedHiddenPages) as unknown as InputSignal>; + + const changes: SimpleChanges = { + hiddenPages: { + currentValue: updatedHiddenPages, + previousValue: initialHiddenPages, + firstChange: false, + isFirstChange: () => false, + }, + }; + component.ngOnChanges(changes); + + expect(component.newHiddenPages()).toEqual(new Set(updatedHiddenPages)); + }); + + it('should load the PDF when currentPdfUrl changes', async () => { + const mockLoadPdf = jest.spyOn(component, 'loadPdf').mockResolvedValue(); + const initialPdfUrl = 'initial.pdf'; + const updatedPdfUrl = 'updated.pdf'; + + let currentPdfUrlValue = initialPdfUrl; + component.currentPdfUrl = jest.fn(() => currentPdfUrlValue) as unknown as InputSignal; + component.appendFile = jest.fn(() => false) as unknown as InputSignal; + + currentPdfUrlValue = updatedPdfUrl; + const changes: SimpleChanges = { + currentPdfUrl: { + currentValue: updatedPdfUrl, + previousValue: initialPdfUrl, + firstChange: false, + isFirstChange: () => false, + }, + }; + await component.ngOnChanges(changes); + + expect(mockLoadPdf).toHaveBeenCalledWith(updatedPdfUrl, false); + }); + it('should load PDF and render pages', async () => { - const spyCreateCanvas = jest.spyOn(component, 'createCanvas'); - const spyCreateCanvasContainer = jest.spyOn(component, 'createCanvasContainer'); + const spyCreateCanvas = jest.spyOn(component as any, 'createCanvas'); - await component.loadOrAppendPdf('fake-url'); + await component.loadPdf('fake-url', false); expect(spyCreateCanvas).toHaveBeenCalled(); - expect(spyCreateCanvasContainer).toHaveBeenCalled(); - expect(component.totalPages()).toBe(1); + expect(component.totalPagesArray().size).toBe(1); }); it('should toggle enlarged view state', () => { const mockCanvas = document.createElement('canvas'); - component.displayEnlargedCanvas(mockCanvas); + component.originalCanvas.set(mockCanvas); + component.isEnlargedView.set(true); expect(component.isEnlargedView()).toBeTruthy(); component.isEnlargedView.set(false); expect(component.isEnlargedView()).toBeFalsy(); }); - it('should handle mouseenter and mouseleave events correctly', () => { - const mockCanvas = document.createElement('canvas'); - const container = component.createCanvasContainer(mockCanvas, 1); - const overlay = container.querySelector('div'); + it('should toggle visibility of a page', () => { + fixture.componentRef.setInput('hiddenPages', new Set([1])); + component.toggleVisibility(1, new MouseEvent('click')); + expect(component.hiddenPages()!.has(1)).toBeFalse(); - container.dispatchEvent(new Event('mouseenter')); - expect(overlay!.style.opacity).toBe('1'); + component.toggleVisibility(2, new MouseEvent('click')); + expect(component.hiddenPages()!.has(2)).toBeTrue(); + }); - container.dispatchEvent(new Event('mouseleave')); - expect(overlay!.style.opacity).toBe('0'); + it('should select and deselect pages', () => { + component.togglePageSelection(1, { target: { checked: true } } as unknown as Event); + expect(component.selectedPages().has(1)).toBeTrue(); + + component.togglePageSelection(1, { target: { checked: false } } as unknown as Event); + expect(component.selectedPages().has(1)).toBeFalse(); }); - it('should handle click event on overlay to trigger displayEnlargedCanvas', () => { - const displayEnlargedCanvasSpy = jest.spyOn(component, 'displayEnlargedCanvas'); + it('should handle enlarged view correctly for a specific page', () => { const mockCanvas = document.createElement('canvas'); - const container = component.createCanvasContainer(mockCanvas, 1); - const overlay = container.querySelector('div'); - - overlay!.dispatchEvent(new Event('click')); - expect(displayEnlargedCanvasSpy).toHaveBeenCalledWith(mockCanvas); + const container = document.createElement('div'); + container.id = 'pdf-page-1'; + container.appendChild(mockCanvas); + + component.pdfContainer = jest.fn(() => ({ + nativeElement: { + querySelector: jest.fn((selector: string) => { + if (selector === '#pdf-page-1 canvas') { + return mockCanvas; + } + return null; + }), + }, + })) as unknown as Signal>; + + component.displayEnlargedCanvas(1); + + expect(component.originalCanvas()).toBe(mockCanvas); + expect(component.isEnlargedView()).toBeTruthy(); }); }); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts index 2f0b2ed366f7..2aca3a32a3dc 100644 --- a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts @@ -1,6 +1,6 @@ import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { MAX_FILE_SIZE } from 'app/shared/constants/input.constants'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { AttachmentService } from 'app/lecture/attachment.service'; @@ -72,6 +72,7 @@ describe('PdfPreviewComponent', () => { getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), update: jest.fn().mockReturnValue(of({})), delete: jest.fn().mockReturnValue(of({})), + getHiddenSlides: jest.fn().mockReturnValue(of([1, 2, 3])), }; lectureUnitServiceMock = { delete: jest.fn().mockReturnValue(of({})), @@ -124,15 +125,23 @@ describe('PdfPreviewComponent', () => { expect(attachmentUnitServiceMock.getAttachmentFile).not.toHaveBeenCalled(); }); - it('should load attachment unit file and verify service calls when attachment unit data is available', () => { + it('should load attachment unit file and verify service calls when attachment unit data is available', fakeAsync(() => { routeMock.data = of({ course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, + attachmentUnit: { + id: 1, + name: 'Chapter 1', + lecture: { id: 1 }, + slides: [ + { slideNumber: 1, hidden: false }, + { slideNumber: 2, hidden: true }, + ], + }, }); component.ngOnInit(); + tick(); expect(attachmentUnitServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); - expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalled(); - }); + })); it('should handle errors and trigger alert when loading an attachment file fails', () => { const errorResponse = new HttpErrorResponse({ @@ -151,11 +160,17 @@ describe('PdfPreviewComponent', () => { expect(alertServiceSpy).toHaveBeenCalled(); }); - it('should handle errors and trigger alert when loading an attachment unit file fails', () => { + it('should handle errors and trigger alert when loading an attachment unit file fails', fakeAsync(() => { routeMock.data = of({ course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, + attachmentUnit: { + id: 1, + name: 'Chapter 1', + lecture: { id: 1 }, + slides: [], + }, }); + const errorResponse = new HttpErrorResponse({ status: 404, statusText: 'Not Found', @@ -167,10 +182,57 @@ describe('PdfPreviewComponent', () => { const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); component.ngOnInit(); - fixture.detectChanges(); + tick(); - expect(alertServiceSpy).toHaveBeenCalled(); - }); + expect(alertServiceSpy).toHaveBeenCalledWith('error.http.404'); + })); + + it('should update attachment unit with student version when there are hidden pages', fakeAsync(() => { + const mockStudentVersion = new File(['pdf content'], 'student.pdf', { type: 'application/pdf' }); + jest.spyOn(component, 'getHiddenPages').mockReturnValue([1, 2, 3]); + jest.spyOn(component, 'createStudentVersionOfAttachment').mockResolvedValue(mockStudentVersion); + + component.currentPdfBlob.set(new Blob(['pdf content'], { type: 'application/pdf' })); + component.attachment.set(undefined); + component.attachmentUnit.set({ + id: 1, + lecture: { id: 1 }, + attachment: { id: 1, name: 'test.pdf' }, + }); + + attachmentUnitServiceMock.update.mockReturnValue(of({})); + + component.updateAttachmentWithFile(); + tick(); + + fixture.whenStable().then(() => { + expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData), undefined, '1,2,3'); + }); + })); + + it('should handle errors when updating attachment unit with hidden pages fails', fakeAsync(() => { + const mockStudentVersion = new File(['pdf content'], 'student.pdf', { type: 'application/pdf' }); + jest.spyOn(component, 'getHiddenPages').mockReturnValue([1, 2]); + jest.spyOn(component, 'createStudentVersionOfAttachment').mockResolvedValue(mockStudentVersion); + + component.currentPdfBlob.set(new Blob(['pdf content'], { type: 'application/pdf' })); + component.attachment.set(undefined); + component.attachmentUnit.set({ + id: 1, + lecture: { id: 1 }, + attachment: { id: 1, name: 'test.pdf' }, + }); + + attachmentUnitServiceMock.update.mockReturnValue(throwError(() => new Error('Update failed'))); + + component.updateAttachmentWithFile(); + fixture.whenStable(); + tick(); + + fixture.whenStable().then(() => { + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); + }); + })); }); describe('Unsubscribing from Observables', () => { @@ -180,18 +242,29 @@ describe('PdfPreviewComponent', () => { expect(spySub).toHaveBeenCalled(); }); - it('should unsubscribe attachmentUnit subscription during component destruction', () => { + it('should unsubscribe attachmentUnit subscription during component destruction', fakeAsync(() => { routeMock.data = of({ course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, + attachmentUnit: { + id: 1, + name: 'Chapter 1', + lecture: { id: 1 }, + slides: [ + { slideNumber: 1, hidden: false }, + { slideNumber: 2, hidden: true }, // Example hidden slide + ], + }, }); + component.ngOnInit(); - fixture.detectChanges(); + tick(); + expect(component.attachmentUnitSub).toBeDefined(); - const spySub = jest.spyOn(component.attachmentUnitSub, 'unsubscribe'); + + const spySub = jest.spyOn(component.attachmentUnitSub!, 'unsubscribe'); component.ngOnDestroy(); expect(spySub).toHaveBeenCalled(); - }); + })); }); describe('File Input Handling', () => { @@ -206,6 +279,40 @@ describe('PdfPreviewComponent', () => { }); }); + describe('Get Hidden Pages', () => { + it('should return an array of hidden page numbers based on button IDs', () => { + document.body.innerHTML = ` + + + + `; + + const hiddenPages = component.getHiddenPages(); + expect(hiddenPages).toEqual([1, 3, 5]); + }); + + it('should return an empty array if no matching elements are found', () => { + document.body.innerHTML = ` + + + `; + + const hiddenPages = component.getHiddenPages(); + expect(hiddenPages).toEqual([]); + }); + + it('should ignore elements without valid IDs', () => { + document.body.innerHTML = ` + + + + `; + + const hiddenPages = component.getHiddenPages(); + expect(hiddenPages).toEqual([1, 2]); + }); + }); + describe('Attachment Updating', () => { it('should update attachment successfully and show success alert', () => { component.attachment.set({ id: 1, version: 1 }); @@ -234,8 +341,13 @@ describe('PdfPreviewComponent', () => { expect(attachmentServiceMock.update).toHaveBeenCalled(); expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); }); + }); + + describe('Attachment Unit Update', () => { + it('should update attachment unit successfully when there are no hidden pages', fakeAsync(() => { + jest.spyOn(component, 'getHiddenPages').mockReturnValue([]); + jest.spyOn(FormData.prototype, 'append'); - it('should update attachment unit successfully and show success alert', () => { component.attachment.set(undefined); component.attachmentUnit.set({ id: 1, @@ -244,27 +356,39 @@ describe('PdfPreviewComponent', () => { }); attachmentUnitServiceMock.update.mockReturnValue(of({})); + routerNavigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate').mockImplementation(() => Promise.resolve(true)); + component.updateAttachmentWithFile(); + tick(); expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); + expect(FormData.prototype.append).toHaveBeenCalledWith('file', expect.any(File)); + expect(FormData.prototype.append).toHaveBeenCalledWith('attachment', expect.any(Blob)); + expect(FormData.prototype.append).toHaveBeenCalledWith('attachmentUnit', expect.any(Blob)); expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - }); + expect(routerNavigateSpy).toHaveBeenCalledWith(['course-management', 1, 'lectures', 1, 'unit-management']); + })); + + it('should handle errors when updating an attachment unit fails', fakeAsync(() => { + jest.spyOn(component, 'getHiddenPages').mockReturnValue([]); + jest.spyOn(FormData.prototype, 'append'); - it('should handle errors when updating an attachment unit fails', () => { component.attachment.set(undefined); component.attachmentUnit.set({ id: 1, lecture: { id: 1 }, attachment: { id: 1, version: 1 }, }); - const errorResponse = { message: 'Update failed' }; - attachmentUnitServiceMock.update.mockReturnValue(throwError(() => errorResponse)); + component.currentPdfBlob.set(new Blob(['PDF content'], { type: 'application/pdf' })); + routerNavigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate').mockImplementation(() => Promise.resolve(true)); + attachmentUnitServiceMock.update = jest.fn().mockReturnValue(throwError(() => new Error('Update failed'))); component.updateAttachmentWithFile(); + tick(); expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); - }); + })); }); describe('PDF Merging', () => { @@ -418,4 +542,63 @@ describe('PdfPreviewComponent', () => { expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Deletion failed' }); }); }); + + describe('Create Student Version of Attachment', () => { + it('should create a new PDF file with specified hidden pages removed', async () => { + const hiddenPages = [2, 4]; + const mockPdfBytes = new Uint8Array([1, 2, 3, 4]).buffer; + const mockFileName = 'test-file'; + const updatedPdfBytes = new Uint8Array([5, 6, 7]).buffer; + + const mockAttachmentUnit = { + attachment: { + name: mockFileName, + }, + }; + + const hiddenPdfDoc = { + removePage: jest.fn(), + save: jest.fn().mockResolvedValue(updatedPdfBytes), + }; + + PDFDocument.load = jest.fn().mockResolvedValue(hiddenPdfDoc); + + component.attachmentUnit.set(mockAttachmentUnit as any); + component.currentPdfBlob.set(new Blob([mockPdfBytes], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(mockPdfBytes); + + const result = await component.createStudentVersionOfAttachment(hiddenPages); + + expect(PDFDocument.load).toHaveBeenCalledWith(mockPdfBytes); + expect(hiddenPdfDoc.removePage).toHaveBeenCalledTimes(hiddenPages.length); + expect(hiddenPdfDoc.removePage).toHaveBeenCalledWith(1); // 2-1 (zero-indexed) + expect(hiddenPdfDoc.removePage).toHaveBeenCalledWith(3); // 4-1 (zero-indexed) + expect(hiddenPdfDoc.save).toHaveBeenCalled(); + expect(result).toBeInstanceOf(File); + expect(result!.name).toBe(`${mockFileName}.pdf`); + expect(result!.type).toBe('application/pdf'); + }); + + it('should handle errors and call alertService.error if something goes wrong', async () => { + const hiddenPages = [2, 4]; + const errorMessage = 'Failed to load PDF'; + PDFDocument.load = jest.fn().mockRejectedValue(new Error(errorMessage)); + + const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); + + const mockAttachmentUnit = { + attachment: { + name: 'test-file', + }, + }; + component.attachmentUnit.set(mockAttachmentUnit as any); + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + const result = await component.createStudentVersionOfAttachment(hiddenPages); + + expect(alertServiceErrorSpy).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.pageDeleteError', { error: errorMessage }); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/http/file.service.spec.ts b/src/test/javascript/spec/component/shared/http/file.service.spec.ts index c8657cd9a70f..c7634538fb17 100644 --- a/src/test/javascript/spec/component/shared/http/file.service.spec.ts +++ b/src/test/javascript/spec/component/shared/http/file.service.spec.ts @@ -192,4 +192,14 @@ describe('FileService', () => { expect(result).toBe(expected); }); }); + + describe('createStudentLink', () => { + it('should return the student version of the given link', () => { + const link = 'http://example.com/course/attachment/file.pdf'; + const expectedStudentLink = 'http://example.com/course/attachment/student/file.pdf'; + + const result = fileService.createStudentLink(link); + expect(result).toBe(expectedStudentLink); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts index 9b1c935ded18..29277f2995a7 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts @@ -363,14 +363,24 @@ describe('MonacoEditorCommunicationActionIntegration', () => { comp.registerAction(lectureAttachmentReferenceAction); const lecture = lectureAttachmentReferenceAction.lecturesWithDetails[2]; const attachmentUnit = lecture.attachmentUnits![0]; + + attachmentUnit.attachment = { + link: '/api/files/attachments/lecture/1/Metis-Attachment.pdf', + studentVersion: 'attachments/lecture/1/Metis-Attachment.pdf', + name: 'Metis-Attachment.pdf', + } as Attachment; + const previousName = attachmentUnit.name; attachmentUnit.name = attachmentUnitNameWithBrackets; - const attachmentUnitFileName = 'Metis-Attachment.pdf'; + + const attachmentUnitFileName = 'lecture/1/Metis-Attachment.pdf'; + lectureAttachmentReferenceAction.executeInCurrentEditor({ reference: ReferenceType.ATTACHMENT_UNITS, lecture, attachmentUnit, }); + attachmentUnit.name = previousName; expect(comp.getText()).toBe(`[lecture-unit]${attachmentUnitNameWithoutBrackets}(${attachmentUnitFileName})[/lecture-unit]`); }); @@ -407,7 +417,15 @@ describe('MonacoEditorCommunicationActionIntegration', () => { comp.registerAction(lectureAttachmentReferenceAction); const lecture = lectureAttachmentReferenceAction.lecturesWithDetails[2]; const attachmentUnit = lecture.attachmentUnits![0]; + + attachmentUnit.attachment = { + link: '/api/files/attachments/Metis-Attachment.pdf', + studentVersion: 'attachments/Metis-Attachment.pdf', + name: 'Metis-Attachment.pdf', + } as Attachment; + const attachmentUnitFileName = 'Metis-Attachment.pdf'; + lectureAttachmentReferenceAction.executeInCurrentEditor({ reference: ReferenceType.ATTACHMENT_UNITS, lecture, @@ -435,14 +453,22 @@ describe('MonacoEditorCommunicationActionIntegration', () => { const lecture = lectureAttachmentReferenceAction.lecturesWithDetails[2]; const attachmentUnit = lecture.attachmentUnits![0]; const slide = attachmentUnit.slides![0]; - const slideLink = 'slides'; + + // Ensure slide has a valid slideImagePath + slide.slideImagePath = 'attachments/attachment-unit/123/slide/slide1.png'; + + const slideLink = 'attachment-unit/123/slide/'; + const slideIndex = 1; + lectureAttachmentReferenceAction.executeInCurrentEditor({ reference: ReferenceType.SLIDE, lecture, attachmentUnit, slide, + slideIndex, }); - expect(comp.getText()).toBe(`[slide]${attachmentUnit.name} Slide ${slide.slideNumber}(${slideLink})[/slide]`); + + expect(comp.getText()).toBe(`[slide]${attachmentUnit.name} Slide ${slideIndex}(${slideLink}${slideIndex})[/slide]`); }); it('should error when incorrectly referencing a slide', () => { diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts index 0e13c1e21090..459fed6cb855 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts @@ -18,4 +18,6 @@ export class MockFileService { replaceLectureAttachmentPrefixAndUnderscores = (link: string) => link; replaceAttachmentPrefixAndUnderscores = (link: string) => link; + + createStudentLink = (link: string) => link; }