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..df646f963f9a 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 = "hidden_link") + private String hiddenLink; + // 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 getHiddenLink() { + return hiddenLink; + } + + public void setHiddenLink(String hiddenLink) { + this.hiddenLink = hiddenLink; + } + 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/web/AttachmentResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java index 6c280a0ce0be..da0aafb5eaec 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,23 +101,24 @@ public ResponseEntity createAttachment(@RequestPart Attachment attac } /** - * PUT /attachments/:id : Updates an existing attachment. + * PUT /attachments/:id : Updates an existing attachment, optionally handling both public and hidden files. * * @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 hiddenFile the file to add as hidden version of the attachment (optional) + * @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 */ @PutMapping(value = "attachments/{attachmentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @EnforceAtLeastEditor public ResponseEntity updateAttachment(@PathVariable Long attachmentId, @RequestPart Attachment attachment, @RequestPart(required = false) MultipartFile file, - @RequestParam(value = "notificationText", required = false) String notificationText) { + @RequestPart(required = false) MultipartFile hiddenFile, @RequestParam(value = "notificationText", required = false) String notificationText) { log.debug("REST request to update Attachment : {}", attachment); attachment.setId(attachmentId); - // Make sure that the original references are preserved. + // Make sure that the original references are preserved Attachment originalAttachment = attachmentRepository.findByIdOrElseThrow(attachment.getId()); attachment.setAttachmentUnit(originalAttachment.getAttachmentUnit()); @@ -131,6 +132,20 @@ public ResponseEntity updateAttachment(@PathVariable Long attachment this.fileService.evictCacheForPath(FilePathService.actualPathForPublicPathOrThrow(oldPath)); } + if (hiddenFile != null) { + // Update hidden file logic + Path basePath = FilePathService.getAttachmentUnitFilePath().resolve(originalAttachment.getAttachmentUnit().getId().toString()); + Path savePath = fileService.saveFile(hiddenFile, basePath, true); + attachment.setHiddenLink(FilePathService.publicPathForActualPath(savePath, originalAttachment.getAttachmentUnit().getId()).toString() + "v2"); + + // Delete the old hidden file + if (originalAttachment.getHiddenLink() != null) { + URI oldHiddenPath = URI.create(originalAttachment.getHiddenLink()); + fileService.schedulePathForDeletion(FilePathService.actualPathForPublicPathOrThrow(oldHiddenPath), 0); + fileService.evictCacheForPath(FilePathService.actualPathForPublicPathOrThrow(oldHiddenPath)); + } + } + Attachment result = attachmentRepository.save(attachment); if (notificationText != null) { groupNotificationService.notifyStudentGroupAboutAttachmentChange(result, notificationText); diff --git a/src/main/resources/config/liquibase/changelog/20241117234100_changelog.xml b/src/main/resources/config/liquibase/changelog/20241129170228_changelog.xml similarity index 72% rename from src/main/resources/config/liquibase/changelog/20241117234100_changelog.xml rename to src/main/resources/config/liquibase/changelog/20241129170228_changelog.xml index b6588d31153a..5a3596f88289 100644 --- a/src/main/resources/config/liquibase/changelog/20241117234100_changelog.xml +++ b/src/main/resources/config/liquibase/changelog/20241129170228_changelog.xml @@ -2,9 +2,13 @@ - + + + + + \ 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 c545405054ae..0fcda3454539 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -33,7 +33,7 @@ - + diff --git a/src/main/webapp/app/entities/attachment.model.ts b/src/main/webapp/app/entities/attachment.model.ts index c070019513ee..a57649828a67 100644 --- a/src/main/webapp/app/entities/attachment.model.ts +++ b/src/main/webapp/app/entities/attachment.model.ts @@ -20,6 +20,7 @@ export class Attachment implements BaseEntity { lecture?: Lecture; exercise?: Exercise; attachmentUnit?: AttachmentUnit; + hiddenLink?: string; constructor() {} } diff --git a/src/main/webapp/app/lecture/attachment.service.ts b/src/main/webapp/app/lecture/attachment.service.ts index 446d26960f8b..ffbbb60fb326 100644 --- a/src/main/webapp/app/lecture/attachment.service.ts +++ b/src/main/webapp/app/lecture/attachment.service.ts @@ -45,14 +45,15 @@ export class AttachmentService { * @param attachmentId the id of the attachment to update * @param attachment the attachment object holding the updated values * @param file the file to save as an attachment if it was changed (optional) + * @param hiddenFile the file to add as hidden version of the attachment (optional) * @param req optional request parameters */ - update(attachmentId: number, attachment: Attachment, file?: File, req?: any): Observable { + update(attachmentId: number, attachment: Attachment, file?: File, hiddenFile?: File, req?: any): Observable { const options = createRequestOption(req); const copy = this.convertAttachmentDatesFromClient(attachment); return this.http - .put(this.resourceUrl + '/' + attachmentId, this.createFormData(copy, file), { params: options, observe: 'response' }) + .put(this.resourceUrl + '/' + attachmentId, this.createFormData(copy, file, hiddenFile), { params: options, observe: 'response' }) .pipe(map((res: EntityResponseType) => this.convertAttachmentResponseDatesFromServer(res))); } @@ -128,12 +129,15 @@ export class AttachmentService { return res; } - private createFormData(attachment: Attachment, file?: File) { + private createFormData(attachment: Attachment, file?: File, hiddenFile?: File) { const formData = new FormData(); formData.append('attachment', objectToJsonBlob(attachment)); if (file) { formData.append('file', file); } + if (hiddenFile) { + formData.append('hiddenFile', hiddenFile); + } return formData; } 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 e76741660e2f..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 @@ -70,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 5e863ea5d7f9..cef513e30327 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, OnChanges, OnDestroy, OnInit, SimpleChanges, 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 cloneDeep from 'lodash-es/cloneDeep'; @Component({ selector: 'jhi-pdf-preview-component', @@ -25,7 +26,7 @@ import { PDFDocument } from 'pdf-lib'; standalone: true, imports: [ArtemisSharedModule, PdfPreviewThumbnailGridComponent], }) -export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { +export class PdfPreviewComponent implements OnInit, OnDestroy { fileInput = viewChild.required>('fileInput'); attachmentSub: Subscription; @@ -44,6 +45,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { isFileChanged = signal(false); selectedPages = signal>(new Set()); allPagesSelected = computed(() => this.selectedPages().size === this.totalPages()); + initialHiddenPages = signal>(new Set()); hiddenPages = signal>(new Set()); // Injected services @@ -80,6 +82,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { this.attachmentUnit.set(data.attachmentUnit); this.attachmentUnitService.getHiddenSlides(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!).subscribe({ next: (hiddenPages: number[]) => { + this.initialHiddenPages.set(new Set(hiddenPages)); this.hiddenPages.set(new Set(hiddenPages)); }, error: (error: HttpErrorResponse) => onError(this.alertService, error), @@ -100,11 +103,15 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { this.attachmentUnitSub?.unsubscribe(); } - ngOnChanges(changes: SimpleChanges): void { - if (changes['hiddenPages']) { - this.isFileChanged.set(true); - } + constructor() { + effect( + () => { + this.hiddenPagesChanged(); + }, + { allowSignalWrites: true }, + ); } + /** * Triggers the file input to select files. */ @@ -112,6 +119,20 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { this.fileInput().nativeElement.click(); } + /** + * 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-". * @@ -121,7 +142,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { return Array.from(document.querySelectorAll('.hide-show-btn.btn-success')) .map((el) => { const match = el.id.match(/hide-show-button-(\d+)/); - return match ? match[1] : null; + return match ? parseInt(match[1], 10) : null; }) .filter((id) => id !== null); } @@ -129,7 +150,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { /** * Updates the existing attachment file or creates a new hidden version of the attachment. */ - updateAttachmentWithFile(): void { + async updateAttachmentWithFile(): Promise { const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); if (pdfFile.size > MAX_FILE_SIZE) { @@ -152,9 +173,9 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { }, }); } else if (this.attachmentUnit()) { - const finalHiddenPages = this.getHiddenPages().join(','); + const finalHiddenPages = this.getHiddenPages(); this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); - this.attachmentToBeEdited()!.version!++; + //this.attachmentToBeEdited()!.version!++; this.attachmentToBeEdited()!.uploadDate = dayjs(); const formData = new FormData(); @@ -162,15 +183,30 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData, undefined, finalHiddenPages).subscribe({ - next: async () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); + if (this.hiddenPagesChanged()) { + const pdfFileWithHiddenPages = await this.createHiddenVersionOfAttachment(finalHiddenPages); + const attachmentWithHiddenPages = cloneDeep(this.attachmentToBeEdited()!); + + this.attachmentService.update(attachmentWithHiddenPages.id!, attachmentWithHiddenPages, undefined, pdfFileWithHiddenPages).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.hiddenAttachmentUpdateSuccess'); + this.attachmentUnitService + .update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData, undefined, finalHiddenPages.join(',')) + .subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.hiddenAttachmentUpdateError', { error: error.message }); + }, + }); + } } } @@ -255,6 +291,30 @@ export class PdfPreviewComponent implements OnInit, OnDestroy, OnChanges { this.hiddenPages.set(updatedHiddenPages); } + /** + * Creates a hidden 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 createHiddenVersionOfAttachment(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.ts b/src/main/webapp/app/overview/course-lectures/attachment-unit/attachment-unit.component.ts index 5634aeda7a5e..c89c2e6c96ea 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 @@ -49,9 +49,10 @@ export class AttachmentUnitComponent extends LectureUnitDirective